Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
378
backend/internal/auth/antigravity/auth.go
Normal file
378
backend/internal/auth/antigravity/auth.go
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
// Package antigravity provides OAuth2 authentication functionality for the Antigravity provider.
|
||||
package antigravity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// TokenResponse represents OAuth token response from Google
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// userInfo represents Google user profile
|
||||
type userInfo struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// AntigravityAuth handles Antigravity OAuth authentication
|
||||
type AntigravityAuth struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewAntigravityAuth creates a new Antigravity auth service.
|
||||
func NewAntigravityAuth(cfg *config.Config, httpClient *http.Client) *AntigravityAuth {
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
if httpClient != nil {
|
||||
return &AntigravityAuth{httpClient: httpClient}
|
||||
}
|
||||
return &AntigravityAuth{
|
||||
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (o *AntigravityAuth) shortUserAgent() string {
|
||||
return misc.AntigravityRequestUserAgent("")
|
||||
}
|
||||
|
||||
func (o *AntigravityAuth) nodeUserAgent() string {
|
||||
return misc.AntigravityOnboardUserUserAgent("")
|
||||
}
|
||||
|
||||
func antigravityLoadCodeAssistMetadata() map[string]string {
|
||||
return map[string]string{
|
||||
"ideType": "ANTIGRAVITY",
|
||||
}
|
||||
}
|
||||
|
||||
func antigravityControlPlaneMetadata(userAgent string) map[string]string {
|
||||
return map[string]string{
|
||||
"ide_type": "ANTIGRAVITY",
|
||||
"ide_version": misc.AntigravityVersionFromUserAgent(userAgent),
|
||||
"ide_name": "antigravity",
|
||||
}
|
||||
}
|
||||
|
||||
func extractCloudaicompanionProject(data map[string]any) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"cloudaicompanionProject", "projectId", "project"} {
|
||||
switch value := data[key].(type) {
|
||||
case string:
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
case map[string]any:
|
||||
if id, ok := value["id"].(string); ok {
|
||||
if trimmed := strings.TrimSpace(id); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaultAntigravityTierID(loadResp map[string]any) string {
|
||||
if tiers, okTiers := loadResp["allowedTiers"].([]any); okTiers {
|
||||
for _, rawTier := range tiers {
|
||||
tier, okTier := rawTier.(map[string]any)
|
||||
if !okTier {
|
||||
continue
|
||||
}
|
||||
if isDefault, okDefault := tier["isDefault"].(bool); !okDefault || !isDefault {
|
||||
continue
|
||||
}
|
||||
if id, okID := tier["id"].(string); okID {
|
||||
if trimmed := strings.TrimSpace(id); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if currentTier, okTier := loadResp["currentTier"].(map[string]any); okTier {
|
||||
if id, okID := currentTier["id"].(string); okID {
|
||||
if trimmed := strings.TrimSpace(id); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
return "free-tier"
|
||||
}
|
||||
|
||||
// BuildAuthURL generates the OAuth authorization URL.
|
||||
func (o *AntigravityAuth) BuildAuthURL(state, redirectURI string) string {
|
||||
if strings.TrimSpace(redirectURI) == "" {
|
||||
redirectURI = fmt.Sprintf("http://localhost:%d/oauth-callback", CallbackPort)
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("access_type", "offline")
|
||||
params.Set("client_id", ClientID)
|
||||
params.Set("prompt", "consent")
|
||||
params.Set("redirect_uri", redirectURI)
|
||||
params.Set("response_type", "code")
|
||||
params.Set("scope", strings.Join(Scopes, " "))
|
||||
params.Set("state", state)
|
||||
return AuthEndpoint + "?" + params.Encode()
|
||||
}
|
||||
|
||||
// ExchangeCodeForTokens exchanges authorization code for access and refresh tokens
|
||||
func (o *AntigravityAuth) ExchangeCodeForTokens(ctx context.Context, code, redirectURI string) (*TokenResponse, error) {
|
||||
data := url.Values{}
|
||||
data.Set("code", code)
|
||||
data.Set("client_id", ClientID)
|
||||
data.Set("client_secret", ClientSecret)
|
||||
data.Set("redirect_uri", redirectURI)
|
||||
data.Set("grant_type", "authorization_code")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, TokenEndpoint, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("antigravity token exchange: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, errDo := o.httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return nil, fmt.Errorf("antigravity token exchange: execute request: %w", errDo)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("antigravity token exchange: close body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
bodyBytes, errRead := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
|
||||
if errRead != nil {
|
||||
return nil, fmt.Errorf("antigravity token exchange: read response: %w", errRead)
|
||||
}
|
||||
body := strings.TrimSpace(string(bodyBytes))
|
||||
if body == "" {
|
||||
return nil, fmt.Errorf("antigravity token exchange: request failed: status %d", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("antigravity token exchange: request failed: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var token TokenResponse
|
||||
if errDecode := json.NewDecoder(resp.Body).Decode(&token); errDecode != nil {
|
||||
return nil, fmt.Errorf("antigravity token exchange: decode response: %w", errDecode)
|
||||
}
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// FetchUserInfo retrieves user email from Google
|
||||
func (o *AntigravityAuth) FetchUserInfo(ctx context.Context, accessToken string) (string, error) {
|
||||
accessToken = strings.TrimSpace(accessToken)
|
||||
if accessToken == "" {
|
||||
return "", fmt.Errorf("antigravity userinfo: missing access token")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, UserInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("antigravity userinfo: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("User-Agent", o.shortUserAgent())
|
||||
|
||||
resp, errDo := o.httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return "", fmt.Errorf("antigravity userinfo: execute request: %w", errDo)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("antigravity userinfo: close body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
bodyBytes, errRead := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
|
||||
if errRead != nil {
|
||||
return "", fmt.Errorf("antigravity userinfo: read response: %w", errRead)
|
||||
}
|
||||
body := strings.TrimSpace(string(bodyBytes))
|
||||
if body == "" {
|
||||
return "", fmt.Errorf("antigravity userinfo: request failed: status %d", resp.StatusCode)
|
||||
}
|
||||
return "", fmt.Errorf("antigravity userinfo: request failed: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
var info userInfo
|
||||
if errDecode := json.NewDecoder(resp.Body).Decode(&info); errDecode != nil {
|
||||
return "", fmt.Errorf("antigravity userinfo: decode response: %w", errDecode)
|
||||
}
|
||||
email := strings.TrimSpace(info.Email)
|
||||
if email == "" {
|
||||
return "", fmt.Errorf("antigravity userinfo: response missing email")
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// FetchProjectID retrieves the project ID for the authenticated user via loadCodeAssist
|
||||
func (o *AntigravityAuth) FetchProjectID(ctx context.Context, accessToken string) (string, error) {
|
||||
userAgent := o.shortUserAgent()
|
||||
loadReqBody := map[string]any{
|
||||
"metadata": antigravityLoadCodeAssistMetadata(),
|
||||
}
|
||||
|
||||
rawBody, errMarshal := json.Marshal(loadReqBody)
|
||||
if errMarshal != nil {
|
||||
return "", fmt.Errorf("marshal request body: %w", errMarshal)
|
||||
}
|
||||
|
||||
endpointURL := fmt.Sprintf("%s/%s:loadCodeAssist", APIEndpoint, APIVersion)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL, strings.NewReader(string(rawBody)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
resp, errDo := o.httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return "", fmt.Errorf("execute request: %w", errDo)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("antigravity loadCodeAssist: close body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
bodyBytes, errRead := io.ReadAll(resp.Body)
|
||||
if errRead != nil {
|
||||
return "", fmt.Errorf("read response: %w", errRead)
|
||||
}
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return "", fmt.Errorf("request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
|
||||
}
|
||||
|
||||
var loadResp map[string]any
|
||||
if errDecode := json.Unmarshal(bodyBytes, &loadResp); errDecode != nil {
|
||||
return "", fmt.Errorf("decode response: %w", errDecode)
|
||||
}
|
||||
|
||||
projectID := extractCloudaicompanionProject(loadResp)
|
||||
|
||||
if projectID == "" {
|
||||
projectID, err = o.OnboardUser(ctx, accessToken, defaultAntigravityTierID(loadResp))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if projectID == "" {
|
||||
return "", fmt.Errorf("project id not found in loadCodeAssist or onboardUser response")
|
||||
}
|
||||
return projectID, nil
|
||||
}
|
||||
|
||||
return projectID, nil
|
||||
}
|
||||
|
||||
// OnboardUser attempts to fetch the project ID via onboardUser by polling for completion
|
||||
func (o *AntigravityAuth) OnboardUser(ctx context.Context, accessToken, tierID string) (string, error) {
|
||||
log.Infof("Antigravity: onboarding user with tier: %s", tierID)
|
||||
userAgent := o.nodeUserAgent()
|
||||
requestBody := map[string]any{
|
||||
"tier_id": tierID,
|
||||
"metadata": antigravityControlPlaneMetadata(userAgent),
|
||||
}
|
||||
|
||||
rawBody, errMarshal := json.Marshal(requestBody)
|
||||
if errMarshal != nil {
|
||||
return "", fmt.Errorf("marshal request body: %w", errMarshal)
|
||||
}
|
||||
|
||||
maxAttempts := 5
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
log.Debugf("Polling attempt %d/%d", attempt, maxAttempts)
|
||||
|
||||
reqCtx := ctx
|
||||
var cancel context.CancelFunc
|
||||
if reqCtx == nil {
|
||||
reqCtx = context.Background()
|
||||
}
|
||||
reqCtx, cancel = context.WithTimeout(reqCtx, 30*time.Second)
|
||||
|
||||
endpointURL := fmt.Sprintf("%s/%s:onboardUser", DailyAPIEndpoint, APIVersion)
|
||||
req, errRequest := http.NewRequestWithContext(reqCtx, http.MethodPost, endpointURL, strings.NewReader(string(rawBody)))
|
||||
if errRequest != nil {
|
||||
cancel()
|
||||
return "", fmt.Errorf("create request: %w", errRequest)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("X-Goog-Api-Client", misc.AntigravityGoogAPIClientUA)
|
||||
|
||||
resp, errDo := o.httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
cancel()
|
||||
return "", fmt.Errorf("execute request: %w", errDo)
|
||||
}
|
||||
|
||||
bodyBytes, errRead := io.ReadAll(resp.Body)
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("close body error: %v", errClose)
|
||||
}
|
||||
cancel()
|
||||
|
||||
if errRead != nil {
|
||||
return "", fmt.Errorf("read response: %w", errRead)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var data map[string]any
|
||||
if errDecode := json.Unmarshal(bodyBytes, &data); errDecode != nil {
|
||||
return "", fmt.Errorf("decode response: %w", errDecode)
|
||||
}
|
||||
|
||||
if done, okDone := data["done"].(bool); okDone && done {
|
||||
projectID := ""
|
||||
if responseData, okResp := data["response"].(map[string]any); okResp {
|
||||
projectID = extractCloudaicompanionProject(responseData)
|
||||
}
|
||||
|
||||
if projectID != "" {
|
||||
log.Infof("Successfully fetched project_id: %s", util.HideAPIKey(projectID))
|
||||
return projectID, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no project_id in response")
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
responsePreview := strings.TrimSpace(string(bodyBytes))
|
||||
if len(responsePreview) > 500 {
|
||||
responsePreview = responsePreview[:500]
|
||||
}
|
||||
|
||||
responseErr := responsePreview
|
||||
if len(responseErr) > 200 {
|
||||
responseErr = responseErr[:200]
|
||||
}
|
||||
return "", fmt.Errorf("http %d: %s", resp.StatusCode, responseErr)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("onboard user did not complete after %d attempts", maxAttempts)
|
||||
}
|
||||
135
backend/internal/auth/antigravity/auth_test.go
Normal file
135
backend/internal/auth/antigravity/auth_test.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package antigravity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestFetchProjectIDFromLoadCodeAssist(t *testing.T) {
|
||||
auth := NewAntigravityAuth(nil, &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.String() != "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" {
|
||||
t.Fatalf("unexpected request URL: %s", req.URL.String())
|
||||
}
|
||||
assertLoadCodeAssistHeaders(t, req)
|
||||
assertJSONContains(t, req, `"ideType":"ANTIGRAVITY"`)
|
||||
return jsonResponse(`{"cloudaicompanionProject":"cogent-snow-4mnnp"}`), nil
|
||||
})})
|
||||
|
||||
projectID, err := auth.FetchProjectID(context.Background(), "access-token")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchProjectID error: %v", err)
|
||||
}
|
||||
if projectID != "cogent-snow-4mnnp" {
|
||||
t.Fatalf("projectID = %q", projectID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchProjectIDFallsBackToDailyOnboardUser(t *testing.T) {
|
||||
var sawOnboard bool
|
||||
auth := NewAntigravityAuth(nil, &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.String() {
|
||||
case "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist":
|
||||
assertLoadCodeAssistHeaders(t, req)
|
||||
return jsonResponse(`{"allowedTiers":[{"id":"free-tier","isDefault":true}]}`), nil
|
||||
case "https://daily-cloudcode-pa.googleapis.com/v1internal:onboardUser":
|
||||
sawOnboard = true
|
||||
assertOnboardUserHeaders(t, req)
|
||||
assertJSONContains(t, req, `"tier_id":"free-tier"`)
|
||||
assertJSONContains(t, req, `"ide_type":"ANTIGRAVITY"`)
|
||||
return jsonResponse(`{
|
||||
"done": true,
|
||||
"response": {
|
||||
"cloudaicompanionProject": {
|
||||
"id": "cogent-snow-4mnnp",
|
||||
"name": "cogent-snow-4mnnp",
|
||||
"projectNumber": "22597072101"
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected request URL: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
})})
|
||||
|
||||
projectID, err := auth.FetchProjectID(context.Background(), "access-token")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchProjectID error: %v", err)
|
||||
}
|
||||
if !sawOnboard {
|
||||
t.Fatalf("expected onboardUser fallback")
|
||||
}
|
||||
if projectID != "cogent-snow-4mnnp" {
|
||||
t.Fatalf("projectID = %q", projectID)
|
||||
}
|
||||
}
|
||||
|
||||
func assertLoadCodeAssistHeaders(t *testing.T, req *http.Request) {
|
||||
t.Helper()
|
||||
if got := req.Header.Get("Authorization"); got != "Bearer access-token" {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
if got := req.Header.Get("Accept"); got != "*/*" {
|
||||
t.Fatalf("Accept = %q", got)
|
||||
}
|
||||
if got := req.Header.Get("X-Goog-Api-Client"); got != "" {
|
||||
t.Fatalf("X-Goog-Api-Client = %q, want empty", got)
|
||||
}
|
||||
userAgent := req.Header.Get("User-Agent")
|
||||
if !strings.HasPrefix(userAgent, "antigravity/hub/") {
|
||||
t.Fatalf("User-Agent = %q", userAgent)
|
||||
}
|
||||
if strings.Contains(userAgent, "google-api-nodejs-client/") {
|
||||
t.Fatalf("User-Agent = %q", userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOnboardUserHeaders(t *testing.T, req *http.Request) {
|
||||
t.Helper()
|
||||
if got := req.Header.Get("Authorization"); got != "Bearer access-token" {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
if got := req.Header.Get("Accept"); got != "*/*" {
|
||||
t.Fatalf("Accept = %q", got)
|
||||
}
|
||||
if got := req.Header.Get("X-Goog-Api-Client"); got != "gl-node/22.21.1" {
|
||||
t.Fatalf("X-Goog-Api-Client = %q", got)
|
||||
}
|
||||
userAgent := req.Header.Get("User-Agent")
|
||||
if !strings.HasPrefix(userAgent, "antigravity/hub/") {
|
||||
t.Fatalf("User-Agent = %q", userAgent)
|
||||
}
|
||||
if !strings.Contains(userAgent, "google-api-nodejs-client/10.3.0") {
|
||||
t.Fatalf("User-Agent = %q", userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
func assertJSONContains(t *testing.T, req *http.Request, want string) {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
bodyText := string(body)
|
||||
req.Body = io.NopCloser(strings.NewReader(bodyText))
|
||||
if !strings.Contains(bodyText, want) {
|
||||
t.Fatalf("body missing %s: %s", want, bodyText)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonResponse(body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
32
backend/internal/auth/antigravity/constants.go
Normal file
32
backend/internal/auth/antigravity/constants.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Package antigravity provides OAuth2 authentication functionality for the Antigravity provider.
|
||||
package antigravity
|
||||
|
||||
// OAuth client credentials and configuration
|
||||
const (
|
||||
ClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||||
ClientSecret = "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"
|
||||
CallbackPort = 51121
|
||||
)
|
||||
|
||||
// Scopes defines the OAuth scopes required for Antigravity authentication
|
||||
var Scopes = []string{
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
}
|
||||
|
||||
// OAuth2 endpoints for Google authentication
|
||||
const (
|
||||
TokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
AuthEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
UserInfoEndpoint = "https://www.googleapis.com/oauth2/v2/userinfo?alt=json"
|
||||
)
|
||||
|
||||
// Antigravity API configuration
|
||||
const (
|
||||
APIEndpoint = "https://cloudcode-pa.googleapis.com"
|
||||
DailyAPIEndpoint = "https://daily-cloudcode-pa.googleapis.com"
|
||||
APIVersion = "v1internal"
|
||||
)
|
||||
16
backend/internal/auth/antigravity/filename.go
Normal file
16
backend/internal/auth/antigravity/filename.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package antigravity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CredentialFileName returns the filename used to persist Antigravity credentials.
|
||||
// It uses the email as a suffix to disambiguate accounts.
|
||||
func CredentialFileName(email string) string {
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
return "antigravity.json"
|
||||
}
|
||||
return fmt.Sprintf("antigravity-%s.json", email)
|
||||
}
|
||||
40
backend/internal/auth/claude/anthropic.go
Normal file
40
backend/internal/auth/claude/anthropic.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package claude
|
||||
|
||||
// PKCECodes holds PKCE verification codes for OAuth2 PKCE flow
|
||||
type PKCECodes struct {
|
||||
// CodeVerifier is the cryptographically random string used to correlate
|
||||
// the authorization request to the token request
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
// CodeChallenge is the SHA256 hash of the code verifier, base64url-encoded
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
}
|
||||
|
||||
// ClaudeTokenData holds OAuth token information from Anthropic
|
||||
type ClaudeTokenData struct {
|
||||
// AccessToken is the OAuth2 access token for API access.
|
||||
AccessToken string `json:"access_token"`
|
||||
// RefreshToken is used to obtain new access tokens.
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
// Email is the Anthropic account email.
|
||||
Email string `json:"email"`
|
||||
// AccountUUID identifies the Anthropic account returned by OAuth.
|
||||
AccountUUID string `json:"account_uuid"`
|
||||
// OrganizationUUID identifies the Anthropic organization returned by OAuth.
|
||||
OrganizationUUID string `json:"organization_uuid"`
|
||||
// OrganizationName is the display name returned by OAuth.
|
||||
OrganizationName string `json:"organization_name"`
|
||||
// Expire is the timestamp of the token expiry.
|
||||
Expire string `json:"expired"`
|
||||
}
|
||||
|
||||
// ClaudeAuthBundle aggregates authentication data after OAuth flow completion
|
||||
type ClaudeAuthBundle struct {
|
||||
// APIKey is the Anthropic API key obtained from token exchange.
|
||||
APIKey string `json:"api_key"`
|
||||
// TokenData contains the OAuth tokens from the authentication flow.
|
||||
TokenData ClaudeTokenData `json:"token_data"`
|
||||
// DeviceIDs contains the single device identity persisted with this credential.
|
||||
DeviceIDs []string `json:"claude_device_ids"`
|
||||
// LastRefresh is the timestamp of the last token refresh.
|
||||
LastRefresh string `json:"last_refresh"`
|
||||
}
|
||||
688
backend/internal/auth/claude/anthropic_auth.go
Normal file
688
backend/internal/auth/claude/anthropic_auth.go
Normal file
|
|
@ -0,0 +1,688 @@
|
|||
// Package claude provides OAuth2 authentication functionality for Anthropic's Claude API.
|
||||
// This package implements the complete OAuth2 flow with PKCE (Proof Key for Code Exchange)
|
||||
// for secure authentication with Claude API, including token exchange, refresh, and storage.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// OAuth configuration constants for Claude/Anthropic
|
||||
const (
|
||||
AuthURL = "https://claude.ai/oauth/authorize"
|
||||
// TokenURL is the authorization-code exchange endpoint. Claude Code 2.1.220
|
||||
// posts the code exchange to platform.claude.com, not api.anthropic.com.
|
||||
TokenURL = "https://platform.claude.com/v1/oauth/token"
|
||||
RefreshTokenURL = "https://platform.claude.com/v1/oauth/token"
|
||||
ProfileURL = "https://api.anthropic.com/api/oauth/profile"
|
||||
// RolesURL is the claude_cli role endpoint the native client queries right
|
||||
// after a successful token exchange, alongside the profile lookup.
|
||||
RolesURL = "https://api.anthropic.com/api/oauth/claude_cli/roles"
|
||||
ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
RedirectURI = "http://localhost:54545/callback"
|
||||
ClaudeOAuthScope = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
|
||||
|
||||
claudeRefreshMinBackoff = 5 * time.Second
|
||||
claudeRefreshMaxBackoff = 5 * time.Minute
|
||||
claudeRefreshTimeout = 30 * time.Second
|
||||
claudeRefreshHandshakeTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
claudeRefreshGroup singleflight.Group
|
||||
claudeRefreshMu sync.Mutex
|
||||
claudeRefreshBlock = make(map[string]time.Time)
|
||||
)
|
||||
|
||||
type refreshHTTPError struct {
|
||||
status int
|
||||
message string
|
||||
retryable bool
|
||||
}
|
||||
|
||||
func (e *refreshHTTPError) Error() string {
|
||||
return fmt.Sprintf("token refresh failed with status %d: %s", e.status, e.message)
|
||||
}
|
||||
|
||||
func (e *refreshHTTPError) Retryable() bool {
|
||||
return e != nil && e.retryable
|
||||
}
|
||||
|
||||
func resetClaudeRefreshState() {
|
||||
claudeRefreshMu.Lock()
|
||||
defer claudeRefreshMu.Unlock()
|
||||
claudeRefreshBlock = make(map[string]time.Time)
|
||||
claudeRefreshGroup = singleflight.Group{}
|
||||
}
|
||||
|
||||
func claudeRefreshBlockedUntil(refreshToken string) time.Time {
|
||||
claudeRefreshMu.Lock()
|
||||
defer claudeRefreshMu.Unlock()
|
||||
return claudeRefreshBlock[refreshToken]
|
||||
}
|
||||
|
||||
func setClaudeRefreshBlockedUntil(refreshToken string, until time.Time) {
|
||||
claudeRefreshMu.Lock()
|
||||
defer claudeRefreshMu.Unlock()
|
||||
claudeRefreshBlock[refreshToken] = until
|
||||
}
|
||||
|
||||
func clearClaudeRefreshBlockedUntil(refreshToken string) {
|
||||
claudeRefreshMu.Lock()
|
||||
defer claudeRefreshMu.Unlock()
|
||||
delete(claudeRefreshBlock, refreshToken)
|
||||
}
|
||||
|
||||
func clampClaudeRefreshBackoff(d time.Duration) time.Duration {
|
||||
if d < claudeRefreshMinBackoff {
|
||||
return claudeRefreshMinBackoff
|
||||
}
|
||||
if d > claudeRefreshMaxBackoff {
|
||||
return claudeRefreshMaxBackoff
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func parseClaudeRetryAfter(resp *http.Response) time.Duration {
|
||||
if resp == nil {
|
||||
return claudeRefreshMinBackoff
|
||||
}
|
||||
if raw := strings.TrimSpace(resp.Header.Get("Retry-After")); raw != "" {
|
||||
if seconds, err := time.ParseDuration(raw + "s"); err == nil {
|
||||
return clampClaudeRefreshBackoff(seconds)
|
||||
}
|
||||
if when, err := http.ParseTime(raw); err == nil {
|
||||
return clampClaudeRefreshBackoff(time.Until(when))
|
||||
}
|
||||
}
|
||||
if raw := strings.TrimSpace(resp.Header.Get("Retry-After-Ms")); raw != "" {
|
||||
if ms, err := time.ParseDuration(raw + "ms"); err == nil {
|
||||
return clampClaudeRefreshBackoff(ms)
|
||||
}
|
||||
}
|
||||
return claudeRefreshMinBackoff
|
||||
}
|
||||
|
||||
func isClaudeRefreshRetryable(err error) bool {
|
||||
var httpErr *refreshHTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr.Retryable()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// tokenResponse represents the response structure from Anthropic's OAuth token endpoint.
|
||||
// It contains access token, refresh token, and associated user/organization information.
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Organization struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
} `json:"organization"`
|
||||
Account struct {
|
||||
UUID string `json:"uuid"`
|
||||
EmailAddress string `json:"email_address"`
|
||||
} `json:"account"`
|
||||
}
|
||||
|
||||
// authorizationCodeExchangeRequest is the authorization-code exchange body.
|
||||
// Field order is significant: it mirrors the key order observed in native
|
||||
// Claude Code 2.1.220 traffic to platform.claude.com/v1/oauth/token.
|
||||
type authorizationCodeExchangeRequest 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"`
|
||||
}
|
||||
|
||||
// OAuthProfile is the account identity returned by Anthropic's OAuth profile endpoint.
|
||||
type OAuthProfile struct {
|
||||
Account struct {
|
||||
UUID string `json:"uuid"`
|
||||
Email string `json:"email"`
|
||||
} `json:"account"`
|
||||
Organization struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
} `json:"organization"`
|
||||
}
|
||||
|
||||
// ClaudeAuth handles Anthropic OAuth2 authentication flow.
|
||||
// It provides methods for generating authorization URLs, exchanging codes for tokens,
|
||||
// and refreshing expired tokens using PKCE for enhanced security.
|
||||
type ClaudeAuth struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClaudeAuth creates a new Anthropic authentication service.
|
||||
// It initializes the HTTP client with a custom TLS transport that uses Firefox
|
||||
// fingerprint to bypass Cloudflare's TLS fingerprinting on Anthropic domains.
|
||||
//
|
||||
// Parameters:
|
||||
// - cfg: The application configuration containing proxy settings
|
||||
//
|
||||
// Returns:
|
||||
// - *ClaudeAuth: A new Claude authentication service instance
|
||||
func NewClaudeAuth(cfg *config.Config) *ClaudeAuth {
|
||||
return NewClaudeAuthWithProxyURL(cfg, "")
|
||||
}
|
||||
|
||||
// NewClaudeAuthWithProxyURL creates a new Anthropic authentication service with a proxy override.
|
||||
// proxyURL takes precedence over cfg.ProxyURL when non-empty.
|
||||
func NewClaudeAuthWithProxyURL(cfg *config.Config, proxyURL string) *ClaudeAuth {
|
||||
effectiveProxyURL := strings.TrimSpace(proxyURL)
|
||||
var sdkCfg *config.SDKConfig
|
||||
if cfg != nil {
|
||||
sdkCfgCopy := cfg.SDKConfig
|
||||
if effectiveProxyURL == "" {
|
||||
effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL)
|
||||
}
|
||||
sdkCfgCopy.ProxyURL = effectiveProxyURL
|
||||
sdkCfg = &sdkCfgCopy
|
||||
} else if effectiveProxyURL != "" {
|
||||
sdkCfgCopy := config.SDKConfig{ProxyURL: effectiveProxyURL}
|
||||
sdkCfg = &sdkCfgCopy
|
||||
}
|
||||
|
||||
// Use custom HTTP client with Firefox TLS fingerprint to bypass
|
||||
// Cloudflare's bot detection on Anthropic domains.
|
||||
return &ClaudeAuth{
|
||||
httpClient: NewAnthropicHttpClient(sdkCfg),
|
||||
}
|
||||
}
|
||||
|
||||
func applyClaudeOAuthAxiosHeaders(req *http.Request) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "axios/1.15.2")
|
||||
req.Header.Set("Accept-Encoding", "gzip, compress, deflate, br")
|
||||
req.Header.Set("Connection", "close")
|
||||
req.Close = true
|
||||
}
|
||||
|
||||
// fetchOAuthControlPlaneJSON issues an Axios-shaped OAuth control-plane GET and
|
||||
// returns the decoded response body. label names the endpoint in error text.
|
||||
func (o *ClaudeAuth) fetchOAuthControlPlaneJSON(ctx context.Context, endpoint, accessToken, label string) ([]byte, error) {
|
||||
if o == nil || o.httpClient == nil {
|
||||
return nil, fmt.Errorf("fetch Claude OAuth %s: HTTP client is nil", label)
|
||||
}
|
||||
accessToken = strings.TrimSpace(accessToken)
|
||||
if accessToken == "" {
|
||||
return nil, fmt.Errorf("fetch Claude OAuth %s: access token is empty", label)
|
||||
}
|
||||
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if errRequest != nil {
|
||||
return nil, fmt.Errorf("create Claude OAuth %s request: %w", label, errRequest)
|
||||
}
|
||||
applyClaudeOAuthAxiosHeaders(req)
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
|
||||
resp, errDo := o.httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return nil, fmt.Errorf("fetch Claude OAuth %s: %w", label, errDo)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("failed to close Claude OAuth %s response body: %v", label, errClose)
|
||||
}
|
||||
}()
|
||||
body, errRead := readClaudeOAuthResponseBody(resp)
|
||||
if errRead != nil {
|
||||
return nil, fmt.Errorf("read Claude OAuth %s response: %w", label, errRead)
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("fetch Claude OAuth %s failed with status %d", label, resp.StatusCode)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// FetchOAuthProfile retrieves the account identity associated with an OAuth access token.
|
||||
func (o *ClaudeAuth) FetchOAuthProfile(ctx context.Context, accessToken string) (*OAuthProfile, error) {
|
||||
body, errFetch := o.fetchOAuthControlPlaneJSON(ctx, ProfileURL, accessToken, "profile")
|
||||
if errFetch != nil {
|
||||
return nil, errFetch
|
||||
}
|
||||
var profile OAuthProfile
|
||||
if errUnmarshal := json.Unmarshal(body, &profile); errUnmarshal != nil {
|
||||
return nil, fmt.Errorf("parse Claude OAuth profile response: %w", errUnmarshal)
|
||||
}
|
||||
if strings.TrimSpace(profile.Account.UUID) == "" {
|
||||
return nil, fmt.Errorf("fetch Claude OAuth profile: response account UUID is empty")
|
||||
}
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
// FetchOAuthRoles performs the claude_cli roles lookup the native client issues
|
||||
// alongside the profile query after a token exchange. Only the request shape is
|
||||
// covered by captured evidence, so the payload stays opaque and is returned raw
|
||||
// instead of being decoded into a guessed structure.
|
||||
func (o *ClaudeAuth) FetchOAuthRoles(ctx context.Context, accessToken string) (json.RawMessage, error) {
|
||||
body, errFetch := o.fetchOAuthControlPlaneJSON(ctx, RolesURL, accessToken, "claude_cli roles")
|
||||
if errFetch != nil {
|
||||
return nil, errFetch
|
||||
}
|
||||
if !json.Valid(body) {
|
||||
return nil, fmt.Errorf("parse Claude OAuth claude_cli roles response: body is not valid JSON")
|
||||
}
|
||||
return json.RawMessage(body), nil
|
||||
}
|
||||
|
||||
// inspectOAuthAccount replays the login companion control-plane calls the native
|
||||
// client makes within roughly 500ms of a successful token exchange: the account
|
||||
// profile lookup followed by the claude_cli roles lookup. Both are advisory, so
|
||||
// failures are logged and never fail the surrounding login.
|
||||
func (o *ClaudeAuth) inspectOAuthAccount(ctx context.Context, accessToken string) *OAuthProfile {
|
||||
profile, errProfile := o.FetchOAuthProfile(ctx, accessToken)
|
||||
if errProfile != nil {
|
||||
log.Warnf("fetch Claude OAuth profile after token exchange: %v", errProfile)
|
||||
profile = nil
|
||||
}
|
||||
if _, errRoles := o.FetchOAuthRoles(ctx, accessToken); errRoles != nil {
|
||||
log.Warnf("fetch Claude OAuth claude_cli roles after token exchange: %v", errRoles)
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// GenerateAuthURL creates the OAuth authorization URL with PKCE.
|
||||
// This method generates a secure authorization URL including PKCE challenge codes
|
||||
// for the OAuth2 flow with Anthropic's API.
|
||||
//
|
||||
// Parameters:
|
||||
// - state: A random state parameter for CSRF protection
|
||||
// - pkceCodes: The PKCE codes for secure code exchange
|
||||
//
|
||||
// Returns:
|
||||
// - string: The complete authorization URL
|
||||
// - string: The state parameter for verification
|
||||
// - error: An error if PKCE codes are missing or URL generation fails
|
||||
func (o *ClaudeAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, string, error) {
|
||||
if pkceCodes == nil {
|
||||
return "", "", fmt.Errorf("PKCE codes are required")
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"code": {"true"},
|
||||
"client_id": {ClientID},
|
||||
"response_type": {"code"},
|
||||
"redirect_uri": {RedirectURI},
|
||||
"scope": {ClaudeOAuthScope},
|
||||
"code_challenge": {pkceCodes.CodeChallenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
"state": {state},
|
||||
}
|
||||
|
||||
authURL := fmt.Sprintf("%s?%s", AuthURL, params.Encode())
|
||||
return authURL, state, nil
|
||||
}
|
||||
|
||||
// parseCodeAndState extracts the authorization code and state from the callback response.
|
||||
// It handles the parsing of the code parameter which may contain additional fragments.
|
||||
//
|
||||
// Parameters:
|
||||
// - code: The raw code parameter from the OAuth callback
|
||||
//
|
||||
// Returns:
|
||||
// - parsedCode: The extracted authorization code
|
||||
// - parsedState: The extracted state parameter if present
|
||||
func (c *ClaudeAuth) parseCodeAndState(code string) (parsedCode, parsedState string) {
|
||||
splits := strings.Split(code, "#")
|
||||
parsedCode = splits[0]
|
||||
if len(splits) > 1 {
|
||||
parsedState = splits[1]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ExchangeCodeForTokens exchanges authorization code for access tokens.
|
||||
// This method implements the OAuth2 token exchange flow using PKCE for security.
|
||||
// It sends the authorization code along with PKCE verifier to get access and refresh tokens.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request
|
||||
// - code: The authorization code received from OAuth callback
|
||||
// - state: The state parameter for verification
|
||||
// - pkceCodes: The PKCE codes for secure verification
|
||||
//
|
||||
// Returns:
|
||||
// - *ClaudeAuthBundle: The complete authentication bundle with tokens
|
||||
// - error: An error if token exchange fails
|
||||
func (o *ClaudeAuth) ExchangeCodeForTokens(ctx context.Context, code, state string, pkceCodes *PKCECodes) (*ClaudeAuthBundle, error) {
|
||||
if pkceCodes == nil {
|
||||
return nil, fmt.Errorf("PKCE codes are required for token exchange")
|
||||
}
|
||||
newCode, newState := o.parseCodeAndState(code)
|
||||
|
||||
// Prepare token exchange request. The struct field order reproduces the key
|
||||
// order Claude Code 2.1.220 emits on the wire; a map would be re-sorted
|
||||
// alphabetically by encoding/json and change the serialized body bytes.
|
||||
reqBody := authorizationCodeExchangeRequest{
|
||||
GrantType: "authorization_code",
|
||||
Code: newCode,
|
||||
RedirectURI: RedirectURI,
|
||||
ClientID: ClientID,
|
||||
CodeVerifier: pkceCodes.CodeVerifier,
|
||||
State: state,
|
||||
}
|
||||
|
||||
// A state fragment appended to the callback code takes precedence.
|
||||
if newState != "" {
|
||||
reqBody.State = newState
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
// log.Debugf("Token exchange request: %s", string(jsonBody))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", TokenURL, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
applyClaudeOAuthAxiosHeaders(req)
|
||||
|
||||
resp, err := o.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token exchange request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("failed to close response body: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := readClaudeOAuthResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read token response: %w", err)
|
||||
}
|
||||
// log.Debugf("Token response: %s", string(body))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
// log.Debugf("Token response: %s", string(body))
|
||||
|
||||
var tokenResp tokenResponse
|
||||
if err = json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
deviceIDs, errDeviceIDs := GenerateDeviceIDPool()
|
||||
if errDeviceIDs != nil {
|
||||
return nil, errDeviceIDs
|
||||
}
|
||||
|
||||
// Create token data.
|
||||
tokenData := ClaudeTokenData{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
Email: tokenResp.Account.EmailAddress,
|
||||
AccountUUID: tokenResp.Account.UUID,
|
||||
OrganizationUUID: tokenResp.Organization.UUID,
|
||||
OrganizationName: tokenResp.Organization.Name,
|
||||
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Replay the native login companion lookups and let the profile response win
|
||||
// where it carries identity the token response omitted.
|
||||
if profile := o.inspectOAuthAccount(ctx, tokenResp.AccessToken); profile != nil {
|
||||
if value := strings.TrimSpace(profile.Account.UUID); value != "" {
|
||||
tokenData.AccountUUID = value
|
||||
}
|
||||
if value := strings.TrimSpace(profile.Account.Email); value != "" {
|
||||
tokenData.Email = value
|
||||
}
|
||||
if value := strings.TrimSpace(profile.Organization.UUID); value != "" {
|
||||
tokenData.OrganizationUUID = value
|
||||
}
|
||||
if value := strings.TrimSpace(profile.Organization.Name); value != "" {
|
||||
tokenData.OrganizationName = value
|
||||
}
|
||||
}
|
||||
|
||||
// Create auth bundle.
|
||||
bundle := &ClaudeAuthBundle{
|
||||
TokenData: tokenData,
|
||||
DeviceIDs: deviceIDs,
|
||||
LastRefresh: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
// RefreshTokens refreshes the access token using the refresh token.
|
||||
// This method exchanges a valid refresh token for a new access token,
|
||||
// extending the user's authenticated session.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request
|
||||
// - refreshToken: The refresh token to use for getting new access token
|
||||
//
|
||||
// Returns:
|
||||
// - *ClaudeTokenData: The new token data with updated access token
|
||||
// - error: An error if token refresh fails
|
||||
func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) {
|
||||
if refreshToken == "" {
|
||||
return nil, fmt.Errorf("refresh token is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if blockedUntil := claudeRefreshBlockedUntil(refreshToken); blockedUntil.After(time.Now()) {
|
||||
return nil, &refreshHTTPError{
|
||||
status: http.StatusTooManyRequests,
|
||||
message: fmt.Sprintf("refresh temporarily blocked until %s", blockedUntil.Format(time.RFC3339)),
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
result, err, _ := claudeRefreshGroup.Do(refreshToken, func() (interface{}, error) {
|
||||
refreshCtx, cancelRefresh := context.WithTimeout(context.WithoutCancel(ctx), claudeRefreshTimeout)
|
||||
defer cancelRefresh()
|
||||
refreshCtx = context.WithValue(refreshCtx, claudeRefreshHandshakeTimeoutContextKey{}, claudeRefreshHandshakeTimeout)
|
||||
return o.refreshTokensSingleFlight(refreshCtx, refreshToken)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenData, ok := result.(*ClaudeTokenData)
|
||||
if !ok || tokenData == nil {
|
||||
return nil, fmt.Errorf("token refresh failed: invalid single-flight result")
|
||||
}
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
func (o *ClaudeAuth) refreshTokensSingleFlight(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) {
|
||||
if blockedUntil := claudeRefreshBlockedUntil(refreshToken); blockedUntil.After(time.Now()) {
|
||||
return nil, &refreshHTTPError{
|
||||
status: http.StatusTooManyRequests,
|
||||
message: fmt.Sprintf("refresh temporarily blocked until %s", blockedUntil.Format(time.RFC3339)),
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"client_id": ClientID,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refreshToken,
|
||||
"scope": ClaudeOAuthScope,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", RefreshTokenURL, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create refresh request: %w", err)
|
||||
}
|
||||
applyClaudeOAuthAxiosHeaders(req)
|
||||
|
||||
resp, err := o.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token refresh request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := readClaudeOAuthResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read refresh response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
message := string(body)
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryAfter := parseClaudeRetryAfter(resp)
|
||||
setClaudeRefreshBlockedUntil(refreshToken, time.Now().Add(retryAfter))
|
||||
return nil, &refreshHTTPError{status: resp.StatusCode, message: message, retryable: false}
|
||||
}
|
||||
return nil, &refreshHTTPError{
|
||||
status: resp.StatusCode,
|
||||
message: message,
|
||||
retryable: resp.StatusCode >= http.StatusInternalServerError,
|
||||
}
|
||||
}
|
||||
|
||||
// log.Debugf("Token response: %s", string(body))
|
||||
|
||||
var tokenResp tokenResponse
|
||||
if err = json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
clearClaudeRefreshBlockedUntil(refreshToken)
|
||||
if strings.TrimSpace(tokenResp.RefreshToken) == "" {
|
||||
tokenResp.RefreshToken = refreshToken
|
||||
}
|
||||
tokenData := &ClaudeTokenData{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||
}
|
||||
profile, errProfile := o.FetchOAuthProfile(ctx, tokenResp.AccessToken)
|
||||
if errProfile != nil {
|
||||
log.Warnf("fetch Claude OAuth profile after refresh: %v", errProfile)
|
||||
return tokenData, nil
|
||||
}
|
||||
tokenData.Email = profile.Account.Email
|
||||
tokenData.AccountUUID = profile.Account.UUID
|
||||
tokenData.OrganizationUUID = profile.Organization.UUID
|
||||
tokenData.OrganizationName = profile.Organization.Name
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
// CreateTokenStorage creates a new ClaudeTokenStorage from auth bundle and user info.
|
||||
// This method converts the authentication bundle into a token storage structure
|
||||
// suitable for persistence and later use.
|
||||
//
|
||||
// Parameters:
|
||||
// - bundle: The authentication bundle containing token data
|
||||
//
|
||||
// Returns:
|
||||
// - *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,
|
||||
AccountUUID: bundle.TokenData.AccountUUID,
|
||||
OrganizationUUID: bundle.TokenData.OrganizationUUID,
|
||||
OrganizationName: bundle.TokenData.OrganizationName,
|
||||
DeviceIDs: append([]string(nil), bundle.DeviceIDs...),
|
||||
Expire: bundle.TokenData.Expire,
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
// RefreshTokensWithRetry refreshes tokens with automatic retry logic.
|
||||
// This method implements exponential backoff retry logic for token refresh operations,
|
||||
// providing resilience against temporary network or service issues.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request
|
||||
// - refreshToken: The refresh token to use
|
||||
// - maxRetries: The maximum number of retry attempts
|
||||
//
|
||||
// Returns:
|
||||
// - *ClaudeTokenData: The refreshed token data
|
||||
// - error: An error if all retry attempts fail
|
||||
func (o *ClaudeAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*ClaudeTokenData, error) {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// Wait before retry
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
tokenData, err := o.RefreshTokens(ctx, refreshToken)
|
||||
if err == nil {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
|
||||
if !isClaudeRefreshRetryable(err) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// UpdateTokenStorage updates an existing token storage with new token data.
|
||||
// This method refreshes the token storage with newly obtained access and refresh tokens,
|
||||
// updating timestamps and expiration information.
|
||||
//
|
||||
// Parameters:
|
||||
// - storage: The existing token storage to update
|
||||
// - tokenData: The new token data to apply
|
||||
func (o *ClaudeAuth) UpdateTokenStorage(storage *ClaudeTokenStorage, tokenData *ClaudeTokenData) {
|
||||
storage.AccessToken = tokenData.AccessToken
|
||||
storage.RefreshToken = tokenData.RefreshToken
|
||||
storage.LastRefresh = time.Now().Format(time.RFC3339)
|
||||
if tokenData.Email != "" {
|
||||
storage.Email = tokenData.Email
|
||||
}
|
||||
if tokenData.AccountUUID != "" {
|
||||
storage.AccountUUID = tokenData.AccountUUID
|
||||
}
|
||||
if tokenData.OrganizationUUID != "" {
|
||||
storage.OrganizationUUID = tokenData.OrganizationUUID
|
||||
}
|
||||
if tokenData.OrganizationName != "" {
|
||||
storage.OrganizationName = tokenData.OrganizationName
|
||||
}
|
||||
storage.Expire = tokenData.Expire
|
||||
}
|
||||
33
backend/internal/auth/claude/anthropic_auth_proxy_test.go
Normal file
33
backend/internal/auth/claude/anthropic_auth_proxy_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
func TestNewClaudeAuthWithProxyURL_OverrideDirectTakesPrecedence(t *testing.T) {
|
||||
cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "socks5://proxy.example.com:1080"}}
|
||||
auth := NewClaudeAuthWithProxyURL(cfg, "direct")
|
||||
|
||||
transport, ok := auth.httpClient.Transport.(*utlsRoundTripper)
|
||||
if !ok || transport == nil {
|
||||
t.Fatalf("expected utlsRoundTripper, got %T", auth.httpClient.Transport)
|
||||
}
|
||||
if transport.dialer != proxy.Direct {
|
||||
t.Fatalf("expected proxy.Direct, got %T", transport.dialer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClaudeAuthWithProxyURL_OverrideProxyAppliedWithoutConfig(t *testing.T) {
|
||||
auth := NewClaudeAuthWithProxyURL(nil, "socks5://proxy.example.com:1080")
|
||||
|
||||
transport, ok := auth.httpClient.Transport.(*utlsRoundTripper)
|
||||
if !ok || transport == nil {
|
||||
t.Fatalf("expected utlsRoundTripper, got %T", auth.httpClient.Transport)
|
||||
}
|
||||
if transport.dialer == proxy.Direct {
|
||||
t.Fatalf("expected proxy dialer, got %T", transport.dialer)
|
||||
}
|
||||
}
|
||||
540
backend/internal/auth/claude/anthropic_auth_test.go
Normal file
540
backend/internal/auth/claude/anthropic_auth_test.go
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestNewAnthropicHttpClientDoesNotSetRequestTimeout(t *testing.T) {
|
||||
if got := NewAnthropicHttpClient(nil).Timeout; got != 0 {
|
||||
t.Fatalf("HTTP client timeout = %s, want zero", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokens_UsesIndependentTimeout(t *testing.T) {
|
||||
resetClaudeRefreshState()
|
||||
defer resetClaudeRefreshState()
|
||||
|
||||
callerCtx, cancelCaller := context.WithCancel(context.Background())
|
||||
cancelCaller()
|
||||
var requestDeadline time.Time
|
||||
auth := &ClaudeAuth{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
var ok bool
|
||||
requestDeadline, ok = req.Context().Deadline()
|
||||
if !ok {
|
||||
t.Fatal("refresh request has no deadline")
|
||||
}
|
||||
if errContext := req.Context().Err(); errContext != nil {
|
||||
t.Fatalf("refresh request context is already done: %v", errContext)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"probe"}`)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := auth.RefreshTokens(callerCtx, "independent-timeout-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected refresh error")
|
||||
}
|
||||
if requestDeadline.IsZero() || !requestDeadline.After(time.Now()) {
|
||||
t.Fatalf("refresh deadline = %v, want a future deadline", requestDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
// jsonResponse builds a canned control-plane response for the fake transport.
|
||||
func jsonResponse(req *http.Request, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeForTokensPersistsUpstreamAccountAndDevicePool(t *testing.T) {
|
||||
auth := &ClaudeAuth{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.String() {
|
||||
case TokenURL:
|
||||
if req.Method != http.MethodPost {
|
||||
t.Fatalf("token request = %s %s, want POST %s", req.Method, req.URL, TokenURL)
|
||||
}
|
||||
return jsonResponse(req, `{
|
||||
"access_token":"access",
|
||||
"refresh_token":"refresh",
|
||||
"token_type":"Bearer",
|
||||
"expires_in":3600,
|
||||
"account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email_address":"user@example.com"},
|
||||
"organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Example Org"}
|
||||
}`), nil
|
||||
case ProfileURL:
|
||||
return jsonResponse(req, `{
|
||||
"account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email":"user@example.com"},
|
||||
"organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Example Org"}
|
||||
}`), nil
|
||||
case RolesURL:
|
||||
return jsonResponse(req, `{"roles":[]}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected OAuth request URL %s", req.URL)
|
||||
return nil, nil
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
bundle, errExchange := auth.ExchangeCodeForTokens(context.Background(), "code", "state", &PKCECodes{CodeVerifier: "verifier"})
|
||||
if errExchange != nil {
|
||||
t.Fatalf("ExchangeCodeForTokens() error = %v", errExchange)
|
||||
}
|
||||
if bundle.TokenData.AccountUUID != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" {
|
||||
t.Fatalf("account UUID = %q, want OAuth response account", bundle.TokenData.AccountUUID)
|
||||
}
|
||||
if bundle.TokenData.OrganizationUUID != "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" || bundle.TokenData.OrganizationName != "Example Org" {
|
||||
t.Fatalf("organization = %q/%q, want OAuth response organization", bundle.TokenData.OrganizationUUID, bundle.TokenData.OrganizationName)
|
||||
}
|
||||
if len(bundle.DeviceIDs) != ClaudeDevicePoolSize {
|
||||
t.Fatalf("device pool length = %d, want %d", len(bundle.DeviceIDs), ClaudeDevicePoolSize)
|
||||
}
|
||||
storage := auth.CreateTokenStorage(bundle)
|
||||
if storage.AccountUUID != bundle.TokenData.AccountUUID || storage.OrganizationUUID != bundle.TokenData.OrganizationUUID {
|
||||
t.Fatalf("storage account identity = %#v, want bundle identity", storage)
|
||||
}
|
||||
if len(storage.DeviceIDs) != ClaudeDevicePoolSize {
|
||||
t.Fatalf("storage device pool length = %d, want %d", len(storage.DeviceIDs), ClaudeDevicePoolSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeForTokensUsesNative220ControlPlaneShape(t *testing.T) {
|
||||
var order []string
|
||||
headers := make(map[string]http.Header)
|
||||
var tokenBody []byte
|
||||
|
||||
auth := &ClaudeAuth{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
order = append(order, req.URL.String())
|
||||
headers[req.URL.String()] = req.Header.Clone()
|
||||
if !req.Close {
|
||||
t.Fatalf("%s request Close = false, want true", req.URL)
|
||||
}
|
||||
switch req.URL.String() {
|
||||
case TokenURL:
|
||||
if req.URL.Host != "platform.claude.com" {
|
||||
t.Fatalf("exchange host = %q, want platform.claude.com", req.URL.Host)
|
||||
}
|
||||
body, errRead := io.ReadAll(req.Body)
|
||||
if errRead != nil {
|
||||
t.Fatal(errRead)
|
||||
}
|
||||
tokenBody = body
|
||||
return jsonResponse(req, `{"access_token":"access","refresh_token":"refresh","expires_in":28800}`), nil
|
||||
case ProfileURL, RolesURL:
|
||||
if req.Method != http.MethodGet {
|
||||
t.Fatalf("%s method = %s, want GET", req.URL, req.Method)
|
||||
}
|
||||
if req.URL.String() == ProfileURL {
|
||||
return jsonResponse(req, `{
|
||||
"account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email":"user@example.com"},
|
||||
"organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Example Org"}
|
||||
}`), nil
|
||||
}
|
||||
return jsonResponse(req, `{"roles":["claude_code_user"]}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected OAuth request URL %s", req.URL)
|
||||
return nil, nil
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
bundle, errExchange := auth.ExchangeCodeForTokens(t.Context(), "auth-code", "state-value", &PKCECodes{CodeVerifier: "verifier"})
|
||||
if errExchange != nil {
|
||||
t.Fatalf("ExchangeCodeForTokens() error = %v", errExchange)
|
||||
}
|
||||
|
||||
wantOrder := []string{TokenURL, ProfileURL, RolesURL}
|
||||
if len(order) != len(wantOrder) {
|
||||
t.Fatalf("request order = %v, want %v", order, wantOrder)
|
||||
}
|
||||
for i, want := range wantOrder {
|
||||
if order[i] != want {
|
||||
t.Fatalf("request order = %v, want %v", order, wantOrder)
|
||||
}
|
||||
}
|
||||
|
||||
// Key order mirrors the captured native exchange body.
|
||||
wantBody := `{"grant_type":"authorization_code","code":"auth-code","redirect_uri":"` + RedirectURI + `","client_id":"` + ClientID + `","code_verifier":"verifier","state":"state-value"}`
|
||||
if got := string(tokenBody); got != wantBody {
|
||||
t.Fatalf("exchange body = %q, want %q", got, wantBody)
|
||||
}
|
||||
|
||||
wantAxios := map[string]string{
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "axios/1.15.2",
|
||||
"Accept-Encoding": "gzip, compress, deflate, br",
|
||||
"Connection": "close",
|
||||
}
|
||||
for _, endpoint := range wantOrder {
|
||||
for name, want := range wantAxios {
|
||||
if got := headers[endpoint].Get(name); got != want {
|
||||
t.Fatalf("%s %s = %q, want %q", endpoint, name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := headers[TokenURL].Get("Authorization"); got != "" {
|
||||
t.Fatalf("exchange Authorization = %q, want unset", got)
|
||||
}
|
||||
for _, endpoint := range []string{ProfileURL, RolesURL} {
|
||||
if got := headers[endpoint].Get("Authorization"); got != "Bearer access" {
|
||||
t.Fatalf("%s Authorization = %q, want the freshly exchanged bearer token", endpoint, got)
|
||||
}
|
||||
if got := headers[endpoint].Get("Cache-Control"); got != "no-cache" {
|
||||
t.Fatalf("%s Cache-Control = %q, want no-cache", endpoint, got)
|
||||
}
|
||||
}
|
||||
|
||||
if bundle.TokenData.AccountUUID != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" {
|
||||
t.Fatalf("account UUID = %q, want the companion profile account", bundle.TokenData.AccountUUID)
|
||||
}
|
||||
if bundle.TokenData.OrganizationUUID != "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" || bundle.TokenData.OrganizationName != "Example Org" {
|
||||
t.Fatalf("organization = %q/%q, want the companion profile organization", bundle.TokenData.OrganizationUUID, bundle.TokenData.OrganizationName)
|
||||
}
|
||||
if bundle.TokenData.Email != "user@example.com" {
|
||||
t.Fatalf("email = %q, want the companion profile email", bundle.TokenData.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeForTokensSurvivesCompanionLookupFailure(t *testing.T) {
|
||||
auth := &ClaudeAuth{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.String() == TokenURL {
|
||||
return jsonResponse(req, `{
|
||||
"access_token":"access",
|
||||
"refresh_token":"refresh",
|
||||
"expires_in":28800,
|
||||
"account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email_address":"token@example.com"},
|
||||
"organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Token Org"}
|
||||
}`), nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
bundle, errExchange := auth.ExchangeCodeForTokens(t.Context(), "code", "state", &PKCECodes{CodeVerifier: "verifier"})
|
||||
if errExchange != nil {
|
||||
t.Fatalf("companion lookup failure must not fail login, got %v", errExchange)
|
||||
}
|
||||
if bundle.TokenData.AccountUUID != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" || bundle.TokenData.Email != "token@example.com" {
|
||||
t.Fatalf("token-response identity must survive companion failure, got %#v", bundle.TokenData)
|
||||
}
|
||||
if bundle.TokenData.OrganizationName != "Token Org" {
|
||||
t.Fatalf("organization = %q, want token-response organization", bundle.TokenData.OrganizationName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokensWithRetry_429BlocksImmediateReplay(t *testing.T) {
|
||||
resetClaudeRefreshState()
|
||||
defer resetClaudeRefreshState()
|
||||
|
||||
var calls int32
|
||||
auth := &ClaudeAuth{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"rate_limited"}`)),
|
||||
Header: http.Header{"Retry-After": []string{"60"}},
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := auth.RefreshTokensWithRetry(context.Background(), "dummy_refresh_token", 3)
|
||||
if err == nil {
|
||||
t.Fatalf("expected 429 refresh error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "status 429") {
|
||||
t.Fatalf("expected status 429 in error, got %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected 1 refresh attempt after 429, got %d", got)
|
||||
}
|
||||
|
||||
_, err = auth.RefreshTokensWithRetry(context.Background(), "dummy_refresh_token", 3)
|
||||
if err == nil {
|
||||
t.Fatalf("expected immediate blocked refresh error")
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected blocked retry to avoid a second refresh call, got %d attempts", got)
|
||||
}
|
||||
if blockedUntil := claudeRefreshBlockedUntil("dummy_refresh_token"); !blockedUntil.After(time.Now()) {
|
||||
t.Fatalf("expected blocked-until timestamp to be set, got %v", blockedUntil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokens_DeduplicatesConcurrentRefresh(t *testing.T) {
|
||||
resetClaudeRefreshState()
|
||||
defer resetClaudeRefreshState()
|
||||
|
||||
var tokenCalls int32
|
||||
var profileCalls int32
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
|
||||
auth := &ClaudeAuth{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.String() {
|
||||
case RefreshTokenURL:
|
||||
atomic.AddInt32(&tokenCalls, 1)
|
||||
once.Do(func() { close(started) })
|
||||
<-release
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"access_token":"new-access",
|
||||
"refresh_token":"new-refresh",
|
||||
"token_type":"Bearer",
|
||||
"expires_in":3600,
|
||||
"scope":"user:profile user:inference"
|
||||
}`)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
case ProfileURL:
|
||||
atomic.AddInt32(&profileCalls, 1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email":"shared@example.com"},
|
||||
"organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Shared Org"}
|
||||
}`)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected OAuth request URL %s", req.URL)
|
||||
return nil, nil
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
results := make(chan *ClaudeTokenData, 2)
|
||||
errs := make(chan error, 2)
|
||||
runRefresh := func() {
|
||||
td, err := auth.RefreshTokens(context.Background(), "shared-refresh-token")
|
||||
results <- td
|
||||
errs <- err
|
||||
}
|
||||
|
||||
go runRefresh()
|
||||
go runRefresh()
|
||||
|
||||
<-started
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&tokenCalls); got != 1 {
|
||||
t.Fatalf("expected concurrent refresh to share a single upstream call, got %d", got)
|
||||
}
|
||||
close(release)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatalf("expected refresh to succeed, got %v", err)
|
||||
}
|
||||
td := <-results
|
||||
if td == nil || td.AccessToken != "new-access" {
|
||||
t.Fatalf("expected refreshed access token, got %#v", td)
|
||||
}
|
||||
if td.AccountUUID != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" {
|
||||
t.Fatalf("account UUID = %q, want OAuth response account", td.AccountUUID)
|
||||
}
|
||||
if td.OrganizationUUID != "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" || td.OrganizationName != "Shared Org" {
|
||||
t.Fatalf("organization = %q/%q, want OAuth response organization", td.OrganizationUUID, td.OrganizationName)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&tokenCalls); got != 1 {
|
||||
t.Fatalf("expected exactly 1 upstream refresh call, got %d", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&profileCalls); got != 1 {
|
||||
t.Fatalf("expected exactly 1 OAuth profile call, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokensUsesNative220ControlPlaneShape(t *testing.T) {
|
||||
resetClaudeRefreshState()
|
||||
defer resetClaudeRefreshState()
|
||||
|
||||
const refreshToken = "placeholder-refresh"
|
||||
auth := &ClaudeAuth{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.String() {
|
||||
case RefreshTokenURL:
|
||||
if req.Method != http.MethodPost {
|
||||
t.Fatalf("refresh method = %s, want POST", req.Method)
|
||||
}
|
||||
body, errRead := io.ReadAll(req.Body)
|
||||
if errRead != nil {
|
||||
t.Fatal(errRead)
|
||||
}
|
||||
wantBody := `{"client_id":"` + ClientID + `","grant_type":"refresh_token","refresh_token":"` + refreshToken + `","scope":"` + ClaudeOAuthScope + `"}`
|
||||
if got := string(body); got != wantBody {
|
||||
t.Fatalf("refresh body = %q, want %q", got, wantBody)
|
||||
}
|
||||
wantHeaders := map[string]string{
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "axios/1.15.2",
|
||||
"Accept-Encoding": "gzip, compress, deflate, br",
|
||||
"Connection": "close",
|
||||
}
|
||||
for name, want := range wantHeaders {
|
||||
if got := req.Header.Get(name); got != want {
|
||||
t.Fatalf("%s = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
if !req.Close {
|
||||
t.Fatal("refresh request Close = false, want true")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"access_token":"new-access","expires_in":3600}`)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
case ProfileURL:
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email":"shared@example.com"},
|
||||
"organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Shared Org"}
|
||||
}`)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected OAuth request URL %s", req.URL)
|
||||
return nil, nil
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
tokenData, errRefresh := auth.RefreshTokens(t.Context(), refreshToken)
|
||||
if errRefresh != nil {
|
||||
t.Fatalf("RefreshTokens() error = %v", errRefresh)
|
||||
}
|
||||
if tokenData.RefreshToken != refreshToken {
|
||||
t.Fatalf("refresh token fallback = %q, want original placeholder", tokenData.RefreshToken)
|
||||
}
|
||||
if tokenData.AccountUUID == "" || tokenData.Email == "" || tokenData.OrganizationUUID == "" {
|
||||
t.Fatalf("profile identity was not populated: %#v", tokenData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchOAuthProfile(t *testing.T) {
|
||||
auth := &ClaudeAuth{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodGet || req.URL.String() != ProfileURL {
|
||||
t.Fatalf("profile request = %s %s, want GET %s", req.Method, req.URL, ProfileURL)
|
||||
}
|
||||
if got := req.Header.Get("Authorization"); got != "Bearer test-access" {
|
||||
t.Fatalf("Authorization = %q, want bearer token", got)
|
||||
}
|
||||
wantHeaders := map[string]string{
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
"User-Agent": "axios/1.15.2",
|
||||
"Accept-Encoding": "gzip, compress, deflate, br",
|
||||
"Connection": "close",
|
||||
}
|
||||
for name, want := range wantHeaders {
|
||||
if got := req.Header.Get(name); got != want {
|
||||
t.Fatalf("%s = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
if !req.Close {
|
||||
t.Fatal("profile request Close = false, want true")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email":"user@example.com"},
|
||||
"organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Example Org"}
|
||||
}`)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
profile, errProfile := auth.FetchOAuthProfile(context.Background(), "test-access")
|
||||
if errProfile != nil {
|
||||
t.Fatalf("FetchOAuthProfile() error = %v", errProfile)
|
||||
}
|
||||
if profile.Account.UUID != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" || profile.Account.Email != "user@example.com" {
|
||||
t.Fatalf("account = %#v, want upstream profile account", profile.Account)
|
||||
}
|
||||
if profile.Organization.UUID != "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" || profile.Organization.Name != "Example Org" {
|
||||
t.Fatalf("organization = %#v, want upstream profile organization", profile.Organization)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTokenStoragePreservesAccountWhenRefreshOmitsIt(t *testing.T) {
|
||||
storage := &ClaudeTokenStorage{
|
||||
Email: "user@example.com",
|
||||
AccountUUID: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
OrganizationUUID: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
OrganizationName: "Example Org",
|
||||
}
|
||||
(&ClaudeAuth{}).UpdateTokenStorage(storage, &ClaudeTokenData{
|
||||
AccessToken: "new-access",
|
||||
RefreshToken: "new-refresh",
|
||||
Expire: "2099-01-01T00:00:00Z",
|
||||
})
|
||||
|
||||
if storage.Email != "user@example.com" {
|
||||
t.Fatalf("email = %q, want preserved", storage.Email)
|
||||
}
|
||||
if storage.AccountUUID != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" {
|
||||
t.Fatalf("account UUID = %q, want preserved", storage.AccountUUID)
|
||||
}
|
||||
if storage.OrganizationUUID != "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" || storage.OrganizationName != "Example Org" {
|
||||
t.Fatalf("organization = %q/%q, want preserved", storage.OrganizationUUID, storage.OrganizationName)
|
||||
}
|
||||
}
|
||||
167
backend/internal/auth/claude/errors.go
Normal file
167
backend/internal/auth/claude/errors.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
// Package claude provides authentication and token management functionality
|
||||
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Claude API.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// OAuthError represents an OAuth-specific error.
|
||||
type OAuthError struct {
|
||||
// Code is the OAuth error code.
|
||||
Code string `json:"error"`
|
||||
// Description is a human-readable description of the error.
|
||||
Description string `json:"error_description,omitempty"`
|
||||
// URI is a URI identifying a human-readable web page with information about the error.
|
||||
URI string `json:"error_uri,omitempty"`
|
||||
// StatusCode is the HTTP status code associated with the error.
|
||||
StatusCode int `json:"-"`
|
||||
}
|
||||
|
||||
// Error returns a string representation of the OAuth error.
|
||||
func (e *OAuthError) Error() string {
|
||||
if e.Description != "" {
|
||||
return fmt.Sprintf("OAuth error %s: %s", e.Code, e.Description)
|
||||
}
|
||||
return fmt.Sprintf("OAuth error: %s", e.Code)
|
||||
}
|
||||
|
||||
// NewOAuthError creates a new OAuth error with the specified code, description, and status code.
|
||||
func NewOAuthError(code, description string, statusCode int) *OAuthError {
|
||||
return &OAuthError{
|
||||
Code: code,
|
||||
Description: description,
|
||||
StatusCode: statusCode,
|
||||
}
|
||||
}
|
||||
|
||||
// AuthenticationError represents authentication-related errors.
|
||||
type AuthenticationError struct {
|
||||
// Type is the type of authentication error.
|
||||
Type string `json:"type"`
|
||||
// Message is a human-readable message describing the error.
|
||||
Message string `json:"message"`
|
||||
// Code is the HTTP status code associated with the error.
|
||||
Code int `json:"code"`
|
||||
// Cause is the underlying error that caused this authentication error.
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Error returns a string representation of the authentication error.
|
||||
func (e *AuthenticationError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("%s: %s (caused by: %v)", e.Type, e.Message, e.Cause)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Type, e.Message)
|
||||
}
|
||||
|
||||
// Common authentication error types.
|
||||
var (
|
||||
// ErrTokenExpired = &AuthenticationError{
|
||||
// Type: "token_expired",
|
||||
// Message: "Access token has expired",
|
||||
// Code: http.StatusUnauthorized,
|
||||
// }
|
||||
|
||||
// ErrInvalidState represents an error for invalid OAuth state parameter.
|
||||
ErrInvalidState = &AuthenticationError{
|
||||
Type: "invalid_state",
|
||||
Message: "OAuth state parameter is invalid",
|
||||
Code: http.StatusBadRequest,
|
||||
}
|
||||
|
||||
// ErrCodeExchangeFailed represents an error when exchanging authorization code for tokens fails.
|
||||
ErrCodeExchangeFailed = &AuthenticationError{
|
||||
Type: "code_exchange_failed",
|
||||
Message: "Failed to exchange authorization code for tokens",
|
||||
Code: http.StatusBadRequest,
|
||||
}
|
||||
|
||||
// ErrServerStartFailed represents an error when starting the OAuth callback server fails.
|
||||
ErrServerStartFailed = &AuthenticationError{
|
||||
Type: "server_start_failed",
|
||||
Message: "Failed to start OAuth callback server",
|
||||
Code: http.StatusInternalServerError,
|
||||
}
|
||||
|
||||
// ErrPortInUse represents an error when the OAuth callback port is already in use.
|
||||
ErrPortInUse = &AuthenticationError{
|
||||
Type: "port_in_use",
|
||||
Message: "OAuth callback port is already in use",
|
||||
Code: 13, // Special exit code for port-in-use
|
||||
}
|
||||
|
||||
// ErrCallbackTimeout represents an error when waiting for OAuth callback times out.
|
||||
ErrCallbackTimeout = &AuthenticationError{
|
||||
Type: "callback_timeout",
|
||||
Message: "Timeout waiting for OAuth callback",
|
||||
Code: http.StatusRequestTimeout,
|
||||
}
|
||||
)
|
||||
|
||||
// NewAuthenticationError creates a new authentication error with a cause based on a base error.
|
||||
func NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError {
|
||||
return &AuthenticationError{
|
||||
Type: baseErr.Type,
|
||||
Message: baseErr.Message,
|
||||
Code: baseErr.Code,
|
||||
Cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
// IsAuthenticationError checks if an error is an authentication error.
|
||||
func IsAuthenticationError(err error) bool {
|
||||
var authenticationError *AuthenticationError
|
||||
ok := errors.As(err, &authenticationError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsOAuthError checks if an error is an OAuth error.
|
||||
func IsOAuthError(err error) bool {
|
||||
var oAuthError *OAuthError
|
||||
ok := errors.As(err, &oAuthError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetUserFriendlyMessage returns a user-friendly error message based on the error type.
|
||||
func GetUserFriendlyMessage(err error) string {
|
||||
switch {
|
||||
case IsAuthenticationError(err):
|
||||
var authErr *AuthenticationError
|
||||
errors.As(err, &authErr)
|
||||
switch authErr.Type {
|
||||
case "token_expired":
|
||||
return "Your authentication has expired. Please log in again."
|
||||
case "token_invalid":
|
||||
return "Your authentication is invalid. Please log in again."
|
||||
case "authentication_required":
|
||||
return "Please log in to continue."
|
||||
case "port_in_use":
|
||||
return "The required port is already in use. Please close any applications using port 3000 and try again."
|
||||
case "callback_timeout":
|
||||
return "Authentication timed out. Please try again."
|
||||
case "browser_open_failed":
|
||||
return "Could not open your browser automatically. Please copy and paste the URL manually."
|
||||
default:
|
||||
return "Authentication failed. Please try again."
|
||||
}
|
||||
case IsOAuthError(err):
|
||||
var oauthErr *OAuthError
|
||||
errors.As(err, &oauthErr)
|
||||
switch oauthErr.Code {
|
||||
case "access_denied":
|
||||
return "Authentication was cancelled or denied."
|
||||
case "invalid_request":
|
||||
return "Invalid authentication request. Please try again."
|
||||
case "server_error":
|
||||
return "Authentication server error. Please try again later."
|
||||
default:
|
||||
return fmt.Sprintf("Authentication failed: %s", oauthErr.Description)
|
||||
}
|
||||
default:
|
||||
return "An unexpected error occurred. Please try again."
|
||||
}
|
||||
}
|
||||
218
backend/internal/auth/claude/html_templates.go
Normal file
218
backend/internal/auth/claude/html_templates.go
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// Package claude provides authentication and token management functionality
|
||||
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Claude API.
|
||||
package claude
|
||||
|
||||
// LoginSuccessHtml is the HTML template displayed to users after successful OAuth authentication.
|
||||
// This template provides a user-friendly success page with options to close the window
|
||||
// or navigate to the Claude platform. It includes automatic window closing functionality
|
||||
// and keyboard accessibility features.
|
||||
const LoginSuccessHtml = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Authentication Successful - Claude</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2310b981'%3E%3Cpath d='M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
background: white;
|
||||
padding: 2.5rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
h1 {
|
||||
color: #1f2937;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.setup-notice {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.setup-notice h3 {
|
||||
color: #92400e;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.setup-notice p {
|
||||
color: #92400e;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.setup-notice a {
|
||||
color: #1d4ed8;
|
||||
text-decoration: none;
|
||||
}
|
||||
.setup-notice a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.button-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
.button-primary:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.button-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
.button-secondary:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.countdown {
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.footer a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="success-icon">✓</div>
|
||||
<h1>Authentication Successful!</h1>
|
||||
<p class="subtitle">You have successfully authenticated with Claude. You can now close this window and return to your terminal to continue.</p>
|
||||
|
||||
{{SETUP_NOTICE}}
|
||||
|
||||
<div class="actions">
|
||||
<button class="button button-primary" onclick="window.close()">
|
||||
<span>Close Window</span>
|
||||
</button>
|
||||
<a href="{{PLATFORM_URL}}" target="_blank" class="button button-secondary">
|
||||
<span>Open Platform</span>
|
||||
<span>↗</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="countdown">
|
||||
This window will close automatically in <span id="countdown">10</span> seconds
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Powered by <a href="https://chatgpt.com" target="_blank">ChatGPT</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let countdown = 10;
|
||||
const countdownElement = document.getElementById('countdown');
|
||||
|
||||
const timer = setInterval(() => {
|
||||
countdown--;
|
||||
countdownElement.textContent = countdown;
|
||||
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
window.close();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Close window when user presses Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus the close button for keyboard accessibility
|
||||
document.querySelector('.button-primary').focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// SetupNoticeHtml is the HTML template for the setup notice section.
|
||||
// This template is embedded within the success page to inform users about
|
||||
// additional setup steps required to complete their Claude account configuration.
|
||||
const SetupNoticeHtml = `
|
||||
<div class="setup-notice">
|
||||
<h3>Additional Setup Required</h3>
|
||||
<p>To complete your setup, please visit the <a href="{{PLATFORM_URL}}" target="_blank">Claude</a> to configure your account.</p>
|
||||
</div>`
|
||||
286
backend/internal/auth/claude/identity.go
Normal file
286
backend/internal/auth/claude/identity.go
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
ClaudeDeviceIDsMetadataKey = "claude_device_ids"
|
||||
ClaudeDevicePoolSize = 1
|
||||
claudeDeviceIDByteSize = 32
|
||||
)
|
||||
|
||||
// claudeDevicePoolMu guards every concurrent access to a Claude credential's
|
||||
// Auth.Metadata map, not just the device pool. A single Auth is shared by all
|
||||
// in-flight requests using that credential, and Go maps are not safe for
|
||||
// concurrent read/write, so the account-profile and refresh paths have to take
|
||||
// the same lock as the pool paths. Reaching into Auth.Metadata directly from a
|
||||
// request path is a data race even when the keys differ.
|
||||
var claudeDevicePoolMu sync.Mutex
|
||||
|
||||
// GenerateDeviceIDPool creates the fixed-size device pool stored with a Claude credential.
|
||||
func GenerateDeviceIDPool() ([]string, error) {
|
||||
deviceIDs := make([]string, 0, ClaudeDevicePoolSize)
|
||||
seen := make(map[string]struct{}, ClaudeDevicePoolSize)
|
||||
for len(deviceIDs) < ClaudeDevicePoolSize {
|
||||
deviceID, errDeviceID := generateDeviceID()
|
||||
if errDeviceID != nil {
|
||||
return nil, errDeviceID
|
||||
}
|
||||
if _, exists := seen[deviceID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[deviceID] = struct{}{}
|
||||
deviceIDs = append(deviceIDs, deviceID)
|
||||
}
|
||||
return deviceIDs, nil
|
||||
}
|
||||
|
||||
func generateDeviceID() (string, error) {
|
||||
data := make([]byte, claudeDeviceIDByteSize)
|
||||
if _, errRead := rand.Read(data); errRead != nil {
|
||||
return "", fmt.Errorf("generate Claude device ID: %w", errRead)
|
||||
}
|
||||
return hex.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
// NormalizeDeviceIDPool returns the first valid device ID in canonical form.
|
||||
func NormalizeDeviceIDPool(raw any) []string {
|
||||
var values []string
|
||||
switch typed := raw.(type) {
|
||||
case []string:
|
||||
values = typed
|
||||
case []any:
|
||||
values = make([]string, 0, len(typed))
|
||||
for _, value := range typed {
|
||||
if text, ok := value.(string); ok {
|
||||
values = append(values, text)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
deviceIDs := make([]string, 0, min(len(values), ClaudeDevicePoolSize))
|
||||
seen := make(map[string]struct{}, ClaudeDevicePoolSize)
|
||||
for _, value := range values {
|
||||
deviceID := strings.ToLower(strings.TrimSpace(value))
|
||||
if !ValidDeviceID(deviceID) {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[deviceID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[deviceID] = struct{}{}
|
||||
deviceIDs = append(deviceIDs, deviceID)
|
||||
if len(deviceIDs) == ClaudeDevicePoolSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return deviceIDs
|
||||
}
|
||||
|
||||
// HasCanonicalDeviceIDPool reports whether raw stores exactly one valid device ID.
|
||||
func HasCanonicalDeviceIDPool(raw any) bool {
|
||||
var values []string
|
||||
switch typed := raw.(type) {
|
||||
case []string:
|
||||
values = typed
|
||||
case []any:
|
||||
values = make([]string, 0, len(typed))
|
||||
for _, value := range typed {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
values = append(values, text)
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
normalized := NormalizeDeviceIDPool(values)
|
||||
return len(values) == ClaudeDevicePoolSize && len(normalized) == ClaudeDevicePoolSize && values[0] == normalized[0]
|
||||
}
|
||||
|
||||
// EnsureDeviceIDPool repairs or creates the single-device pool in credential metadata.
|
||||
func EnsureDeviceIDPool(metadata map[string]any) ([]string, bool, error) {
|
||||
claudeDevicePoolMu.Lock()
|
||||
defer claudeDevicePoolMu.Unlock()
|
||||
|
||||
return ensureDeviceIDPoolLocked(metadata)
|
||||
}
|
||||
|
||||
// EnsureDeviceIDPoolFor lazily initializes the metadata map and then ensures the
|
||||
// pool, both under the device pool lock.
|
||||
//
|
||||
// A single *Auth is shared by every concurrent request that selects the same
|
||||
// credential, so initializing the map field outside this lock races with the
|
||||
// writes below and can abort the process with "concurrent map writes". Callers
|
||||
// holding a shared credential must reach the pool through this package rather
|
||||
// than touching the map directly.
|
||||
func EnsureDeviceIDPoolFor(metadata *map[string]any) ([]string, bool, error) {
|
||||
if metadata == nil {
|
||||
return nil, false, fmt.Errorf("ensure Claude device pool: metadata pointer is nil")
|
||||
}
|
||||
claudeDevicePoolMu.Lock()
|
||||
defer claudeDevicePoolMu.Unlock()
|
||||
|
||||
if *metadata == nil {
|
||||
*metadata = make(map[string]any)
|
||||
}
|
||||
return ensureDeviceIDPoolLocked(*metadata)
|
||||
}
|
||||
|
||||
// ReadDeviceIDPool returns the stored pool value, initializing the map when
|
||||
// needed, under the device pool lock. Slice values are copied so a caller can
|
||||
// never mutate the stored credential identity after the lock is released.
|
||||
func ReadDeviceIDPool(metadata *map[string]any) any {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
claudeDevicePoolMu.Lock()
|
||||
defer claudeDevicePoolMu.Unlock()
|
||||
|
||||
if *metadata == nil {
|
||||
*metadata = make(map[string]any)
|
||||
return nil
|
||||
}
|
||||
switch stored := (*metadata)[ClaudeDeviceIDsMetadataKey].(type) {
|
||||
case []string:
|
||||
return append([]string(nil), stored...)
|
||||
case []any:
|
||||
return append([]any(nil), stored...)
|
||||
default:
|
||||
return stored
|
||||
}
|
||||
}
|
||||
|
||||
// StoreDeviceIDPool writes a defensive copy of deviceIDs under the device pool lock.
|
||||
func StoreDeviceIDPool(metadata *map[string]any, deviceIDs []string) {
|
||||
if metadata == nil {
|
||||
return
|
||||
}
|
||||
claudeDevicePoolMu.Lock()
|
||||
defer claudeDevicePoolMu.Unlock()
|
||||
|
||||
if *metadata == nil {
|
||||
*metadata = make(map[string]any)
|
||||
}
|
||||
(*metadata)[ClaudeDeviceIDsMetadataKey] = append([]string(nil), deviceIDs...)
|
||||
}
|
||||
|
||||
// ReadMetadataString reads a string-valued metadata entry under the metadata
|
||||
// lock, so it cannot observe a map being concurrently written by another path.
|
||||
func ReadMetadataString(metadata *map[string]any, key string) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
}
|
||||
claudeDevicePoolMu.Lock()
|
||||
defer claudeDevicePoolMu.Unlock()
|
||||
|
||||
if *metadata == nil {
|
||||
return ""
|
||||
}
|
||||
value, _ := (*metadata)[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
// StoreMetadataString writes a string-valued metadata entry under the metadata
|
||||
// lock, initializing the map when needed. Empty values are skipped so callers can
|
||||
// forward optional fields without erasing a previously resolved value.
|
||||
func StoreMetadataString(metadata *map[string]any, key, value string) {
|
||||
if metadata == nil || strings.TrimSpace(value) == "" {
|
||||
return
|
||||
}
|
||||
claudeDevicePoolMu.Lock()
|
||||
defer claudeDevicePoolMu.Unlock()
|
||||
|
||||
if *metadata == nil {
|
||||
*metadata = make(map[string]any)
|
||||
}
|
||||
(*metadata)[key] = value
|
||||
}
|
||||
|
||||
// StoreMetadataValue writes an arbitrary metadata entry under the metadata lock,
|
||||
// initializing the map when needed.
|
||||
func StoreMetadataValue(metadata *map[string]any, key string, value any) {
|
||||
if metadata == nil {
|
||||
return
|
||||
}
|
||||
claudeDevicePoolMu.Lock()
|
||||
defer claudeDevicePoolMu.Unlock()
|
||||
|
||||
if *metadata == nil {
|
||||
*metadata = make(map[string]any)
|
||||
}
|
||||
(*metadata)[key] = value
|
||||
}
|
||||
|
||||
// EnsureMetadataMap initializes the metadata map under the metadata lock.
|
||||
func EnsureMetadataMap(metadata *map[string]any) {
|
||||
if metadata == nil {
|
||||
return
|
||||
}
|
||||
claudeDevicePoolMu.Lock()
|
||||
defer claudeDevicePoolMu.Unlock()
|
||||
|
||||
if *metadata == nil {
|
||||
*metadata = make(map[string]any)
|
||||
}
|
||||
}
|
||||
|
||||
// ensureDeviceIDPoolLocked requires claudeDevicePoolMu to be held.
|
||||
func ensureDeviceIDPoolLocked(metadata map[string]any) ([]string, bool, error) {
|
||||
if metadata == nil {
|
||||
return nil, false, fmt.Errorf("ensure Claude device pool: metadata is nil")
|
||||
}
|
||||
rawDeviceIDs := metadata[ClaudeDeviceIDsMetadataKey]
|
||||
deviceIDs := NormalizeDeviceIDPool(rawDeviceIDs)
|
||||
changed := !HasCanonicalDeviceIDPool(rawDeviceIDs)
|
||||
seen := make(map[string]struct{}, ClaudeDevicePoolSize)
|
||||
for _, deviceID := range deviceIDs {
|
||||
seen[deviceID] = struct{}{}
|
||||
}
|
||||
for len(deviceIDs) < ClaudeDevicePoolSize {
|
||||
deviceID, errDeviceID := generateDeviceID()
|
||||
if errDeviceID != nil {
|
||||
return nil, false, errDeviceID
|
||||
}
|
||||
if _, exists := seen[deviceID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[deviceID] = struct{}{}
|
||||
deviceIDs = append(deviceIDs, deviceID)
|
||||
}
|
||||
|
||||
if changed {
|
||||
metadata[ClaudeDeviceIDsMetadataKey] = append([]string(nil), deviceIDs...)
|
||||
}
|
||||
return append([]string(nil), deviceIDs...), changed, nil
|
||||
}
|
||||
|
||||
// SelectDeviceID returns the credential's sole device ID after validating the conversation session.
|
||||
func SelectDeviceID(deviceIDs []string, sessionID string) (string, error) {
|
||||
deviceIDs = NormalizeDeviceIDPool(deviceIDs)
|
||||
if len(deviceIDs) != ClaudeDevicePoolSize {
|
||||
return "", fmt.Errorf("select Claude device ID: device pool has %d entries, want %d", len(deviceIDs), ClaudeDevicePoolSize)
|
||||
}
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return "", fmt.Errorf("select Claude device ID: session ID is empty")
|
||||
}
|
||||
return deviceIDs[0], nil
|
||||
}
|
||||
|
||||
// ValidDeviceID reports whether a value matches Claude Code's lowercase 64-hex device format.
|
||||
func ValidDeviceID(value string) bool {
|
||||
if len(value) != claudeDeviceIDByteSize*2 || value != strings.ToLower(value) {
|
||||
return false
|
||||
}
|
||||
decoded, errDecode := hex.DecodeString(value)
|
||||
return errDecode == nil && len(decoded) == claudeDeviceIDByteSize
|
||||
}
|
||||
195
backend/internal/auth/claude/identity_test.go
Normal file
195
backend/internal/auth/claude/identity_test.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateDeviceIDPool(t *testing.T) {
|
||||
deviceIDs, errGenerate := GenerateDeviceIDPool()
|
||||
if errGenerate != nil {
|
||||
t.Fatalf("GenerateDeviceIDPool() error = %v", errGenerate)
|
||||
}
|
||||
if len(deviceIDs) != ClaudeDevicePoolSize {
|
||||
t.Fatalf("device pool length = %d, want %d", len(deviceIDs), ClaudeDevicePoolSize)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(deviceIDs))
|
||||
for _, deviceID := range deviceIDs {
|
||||
if !ValidDeviceID(deviceID) {
|
||||
t.Fatalf("device ID = %q, want 64 lowercase hex", deviceID)
|
||||
}
|
||||
if _, exists := seen[deviceID]; exists {
|
||||
t.Fatalf("duplicate device ID %q", deviceID)
|
||||
}
|
||||
seen[deviceID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadDeviceIDPoolReturnsDefensiveCopy pins that neither side of the device
|
||||
// pool accessors hands out the live stored slice. A caller mutating a result must
|
||||
// never be able to rewrite credential identity outside the device pool lock.
|
||||
func TestReadDeviceIDPoolReturnsDefensiveCopy(t *testing.T) {
|
||||
metadata := map[string]any{}
|
||||
input := []string{"device-a", "device-b", "device-c"}
|
||||
StoreDeviceIDPool(&metadata, input)
|
||||
|
||||
// Write side: mutating the caller's input must not affect stored state.
|
||||
input[0] = "mutated-input"
|
||||
stored, ok := ReadDeviceIDPool(&metadata).([]string)
|
||||
if !ok {
|
||||
t.Fatalf("ReadDeviceIDPool() type = %T, want []string", ReadDeviceIDPool(&metadata))
|
||||
}
|
||||
if stored[0] != "device-a" {
|
||||
t.Fatalf("stored[0] = %q, want %q; write side is not defensive", stored[0], "device-a")
|
||||
}
|
||||
|
||||
// Read side: mutating the returned slice must not affect stored state.
|
||||
stored[0] = "hijacked-device-id"
|
||||
reread, _ := ReadDeviceIDPool(&metadata).([]string)
|
||||
if reread[0] != "device-a" {
|
||||
t.Fatalf("stored[0] = %q after mutating the read result, want %q", reread[0], "device-a")
|
||||
}
|
||||
|
||||
// A []any pool (as produced by JSON unmarshalling) must be copied too.
|
||||
jsonMetadata := map[string]any{ClaudeDeviceIDsMetadataKey: []any{"json-a", "json-b"}}
|
||||
jsonStored, ok := ReadDeviceIDPool(&jsonMetadata).([]any)
|
||||
if !ok {
|
||||
t.Fatalf("ReadDeviceIDPool() type = %T, want []any", ReadDeviceIDPool(&jsonMetadata))
|
||||
}
|
||||
jsonStored[0] = "hijacked"
|
||||
jsonReread, _ := ReadDeviceIDPool(&jsonMetadata).([]any)
|
||||
if jsonReread[0] != "json-a" {
|
||||
t.Fatalf("stored[0] = %v after mutating the read result, want %q", jsonReread[0], "json-a")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDeviceIDPoolRepairsAndStabilizesCredentialMetadata(t *testing.T) {
|
||||
const first = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
metadata := map[string]any{
|
||||
ClaudeDeviceIDsMetadataKey: []any{
|
||||
first,
|
||||
first,
|
||||
"INVALID",
|
||||
},
|
||||
}
|
||||
|
||||
deviceIDs, changed, errEnsure := EnsureDeviceIDPool(metadata)
|
||||
if errEnsure != nil {
|
||||
t.Fatalf("EnsureDeviceIDPool() error = %v", errEnsure)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("EnsureDeviceIDPool() changed = false, want true")
|
||||
}
|
||||
if len(deviceIDs) != ClaudeDevicePoolSize || deviceIDs[0] != first {
|
||||
t.Fatalf("device IDs = %#v, want repaired single-entry pool preserving first", deviceIDs)
|
||||
}
|
||||
|
||||
second, changedAgain, errEnsureAgain := EnsureDeviceIDPool(metadata)
|
||||
if errEnsureAgain != nil {
|
||||
t.Fatalf("EnsureDeviceIDPool() second error = %v", errEnsureAgain)
|
||||
}
|
||||
if changedAgain {
|
||||
t.Fatal("EnsureDeviceIDPool() second changed = true, want stable canonical pool")
|
||||
}
|
||||
if !reflect.DeepEqual(second, deviceIDs) {
|
||||
t.Fatalf("second device IDs = %#v, want %#v", second, deviceIDs)
|
||||
}
|
||||
|
||||
second[0] = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
stored := metadata[ClaudeDeviceIDsMetadataKey].([]string)
|
||||
if stored[0] != first {
|
||||
t.Fatal("returned pool aliases credential metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDeviceIDPoolCanonicalizesSingleDevice(t *testing.T) {
|
||||
const canonical = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
metadata := map[string]any{ClaudeDeviceIDsMetadataKey: []any{" AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA "}}
|
||||
deviceIDs, changed, errEnsure := EnsureDeviceIDPool(metadata)
|
||||
if errEnsure != nil {
|
||||
t.Fatalf("EnsureDeviceIDPool() error = %v", errEnsure)
|
||||
}
|
||||
if !changed || len(deviceIDs) != 1 || deviceIDs[0] != canonical {
|
||||
t.Fatalf("EnsureDeviceIDPool() = %#v, changed=%v; want canonical single device", deviceIDs, changed)
|
||||
}
|
||||
if !HasCanonicalDeviceIDPool(metadata[ClaudeDeviceIDsMetadataKey]) {
|
||||
t.Fatalf("stored device pool = %#v, want canonical", metadata[ClaudeDeviceIDsMetadataKey])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDeviceIDPoolMigratesFiveSlotsToOne(t *testing.T) {
|
||||
metadata := map[string]any{ClaudeDeviceIDsMetadataKey: []string{
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"2222222222222222222222222222222222222222222222222222222222222222",
|
||||
"3333333333333333333333333333333333333333333333333333333333333333",
|
||||
"4444444444444444444444444444444444444444444444444444444444444444",
|
||||
}}
|
||||
|
||||
deviceIDs, changed, errEnsure := EnsureDeviceIDPool(metadata)
|
||||
if errEnsure != nil {
|
||||
t.Fatalf("EnsureDeviceIDPool() error = %v", errEnsure)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("EnsureDeviceIDPool() changed = false, want five-slot migration")
|
||||
}
|
||||
want := []string{"0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
if !reflect.DeepEqual(deviceIDs, want) {
|
||||
t.Fatalf("device IDs = %#v, want %#v", deviceIDs, want)
|
||||
}
|
||||
if stored, ok := metadata[ClaudeDeviceIDsMetadataKey].([]string); !ok || !reflect.DeepEqual(stored, want) {
|
||||
t.Fatalf("stored device IDs = %#v, want %#v", metadata[ClaudeDeviceIDsMetadataKey], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDeviceIDPoolConcurrentInitialization(t *testing.T) {
|
||||
metadata := make(map[string]any)
|
||||
const workers = 20
|
||||
results := make(chan []string, workers)
|
||||
errors := make(chan error, workers)
|
||||
var group sync.WaitGroup
|
||||
for range workers {
|
||||
group.Go(func() {
|
||||
deviceIDs, _, errEnsure := EnsureDeviceIDPool(metadata)
|
||||
results <- deviceIDs
|
||||
errors <- errEnsure
|
||||
})
|
||||
}
|
||||
group.Wait()
|
||||
close(results)
|
||||
close(errors)
|
||||
|
||||
for errEnsure := range errors {
|
||||
if errEnsure != nil {
|
||||
t.Fatalf("EnsureDeviceIDPool() concurrent error = %v", errEnsure)
|
||||
}
|
||||
}
|
||||
stored := NormalizeDeviceIDPool(metadata[ClaudeDeviceIDsMetadataKey])
|
||||
if len(stored) != ClaudeDevicePoolSize {
|
||||
t.Fatalf("stored device pool length = %d, want %d", len(stored), ClaudeDevicePoolSize)
|
||||
}
|
||||
for result := range results {
|
||||
if !reflect.DeepEqual(result, stored) {
|
||||
t.Fatalf("concurrent result = %#v, want %#v", result, stored)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDeviceIDUsesOneDeviceAcrossSessions(t *testing.T) {
|
||||
deviceIDs := []string{
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
}
|
||||
|
||||
first, errFirst := SelectDeviceID(deviceIDs, "11111111-2222-4333-8444-555555555555")
|
||||
if errFirst != nil {
|
||||
t.Fatalf("SelectDeviceID() error = %v", errFirst)
|
||||
}
|
||||
second, errSecond := SelectDeviceID(deviceIDs, "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee")
|
||||
if errSecond != nil {
|
||||
t.Fatalf("SelectDeviceID() second error = %v", errSecond)
|
||||
}
|
||||
if first != second || first != deviceIDs[0] {
|
||||
t.Fatalf("single device selection = %q then %q, want %q", first, second, deviceIDs[0])
|
||||
}
|
||||
}
|
||||
72
backend/internal/auth/claude/oauth_response.go
Normal file
72
backend/internal/auth/claude/oauth_response.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"compress/lzw"
|
||||
"compress/zlib"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
)
|
||||
|
||||
func readClaudeOAuthResponseBody(resp *http.Response) ([]byte, error) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return nil, fmt.Errorf("read Claude OAuth response: body is nil")
|
||||
}
|
||||
encoded, errRead := io.ReadAll(resp.Body)
|
||||
if errRead != nil {
|
||||
return nil, errRead
|
||||
}
|
||||
encodings := strings.Split(strings.Join(resp.Header.Values("Content-Encoding"), ","), ",")
|
||||
for index := len(encodings) - 1; index >= 0; index-- {
|
||||
encoding := strings.ToLower(strings.TrimSpace(encodings[index]))
|
||||
if encoding == "" || encoding == "identity" {
|
||||
continue
|
||||
}
|
||||
var errDecode error
|
||||
encoded, errDecode = decodeClaudeOAuthEncoding(encoded, encoding)
|
||||
if errDecode != nil {
|
||||
return nil, errDecode
|
||||
}
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func decodeClaudeOAuthEncoding(encoded []byte, encoding string) ([]byte, error) {
|
||||
var reader io.ReadCloser
|
||||
switch encoding {
|
||||
case "gzip":
|
||||
gzipReader, errGzip := gzip.NewReader(bytes.NewReader(encoded))
|
||||
if errGzip != nil {
|
||||
return nil, fmt.Errorf("decode Claude OAuth gzip response: %w", errGzip)
|
||||
}
|
||||
reader = gzipReader
|
||||
case "deflate":
|
||||
zlibReader, errZlib := zlib.NewReader(bytes.NewReader(encoded))
|
||||
if errZlib == nil {
|
||||
reader = zlibReader
|
||||
} else {
|
||||
reader = flate.NewReader(bytes.NewReader(encoded))
|
||||
}
|
||||
case "br":
|
||||
reader = io.NopCloser(brotli.NewReader(bytes.NewReader(encoded)))
|
||||
case "compress":
|
||||
reader = lzw.NewReader(bytes.NewReader(encoded), lzw.MSB, 8)
|
||||
default:
|
||||
return nil, fmt.Errorf("decode Claude OAuth response: unsupported content encoding %q", encoding)
|
||||
}
|
||||
decoded, errDecoded := io.ReadAll(reader)
|
||||
if errDecoded != nil {
|
||||
_ = reader.Close()
|
||||
return nil, fmt.Errorf("decode Claude OAuth %s response: %w", encoding, errDecoded)
|
||||
}
|
||||
if errClose := reader.Close(); errClose != nil {
|
||||
return nil, fmt.Errorf("close Claude OAuth %s decoder: %w", encoding, errClose)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
108
backend/internal/auth/claude/oauth_response_test.go
Normal file
108
backend/internal/auth/claude/oauth_response_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
)
|
||||
|
||||
func TestReadClaudeOAuthResponseBodyDecodesStackedRepeatedHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := []byte(`{"account":{"uuid":"test"}}`)
|
||||
var gzipOutput bytes.Buffer
|
||||
gzipWriter := gzip.NewWriter(&gzipOutput)
|
||||
if _, errWrite := gzipWriter.Write(payload); errWrite != nil {
|
||||
t.Fatal(errWrite)
|
||||
}
|
||||
if errClose := gzipWriter.Close(); errClose != nil {
|
||||
t.Fatal(errClose)
|
||||
}
|
||||
var brotliOutput bytes.Buffer
|
||||
brotliWriter := brotli.NewWriter(&brotliOutput)
|
||||
if _, errWrite := brotliWriter.Write(gzipOutput.Bytes()); errWrite != nil {
|
||||
t.Fatal(errWrite)
|
||||
}
|
||||
if errClose := brotliWriter.Close(); errClose != nil {
|
||||
t.Fatal(errClose)
|
||||
}
|
||||
|
||||
header := make(http.Header)
|
||||
header.Add("Content-Encoding", "gzip")
|
||||
header.Add("Content-Encoding", "br")
|
||||
resp := &http.Response{
|
||||
Header: header,
|
||||
Body: io.NopCloser(bytes.NewReader(brotliOutput.Bytes())),
|
||||
}
|
||||
got, errRead := readClaudeOAuthResponseBody(resp)
|
||||
if errRead != nil {
|
||||
t.Fatal(errRead)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("decoded body = %q, want %q", got, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadClaudeOAuthResponseBodyDecodesAdvertisedEncodings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const payload = `{"account":{"uuid":"test"}}`
|
||||
tests := []struct {
|
||||
name string
|
||||
encoding string
|
||||
encode func(testing.TB, []byte) []byte
|
||||
}{
|
||||
{
|
||||
name: "gzip",
|
||||
encoding: "gzip",
|
||||
encode: func(tb testing.TB, input []byte) []byte {
|
||||
tb.Helper()
|
||||
var output bytes.Buffer
|
||||
writer := gzip.NewWriter(&output)
|
||||
if _, errWrite := writer.Write(input); errWrite != nil {
|
||||
tb.Fatal(errWrite)
|
||||
}
|
||||
if errClose := writer.Close(); errClose != nil {
|
||||
tb.Fatal(errClose)
|
||||
}
|
||||
return output.Bytes()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "brotli",
|
||||
encoding: "br",
|
||||
encode: func(tb testing.TB, input []byte) []byte {
|
||||
tb.Helper()
|
||||
var output bytes.Buffer
|
||||
writer := brotli.NewWriter(&output)
|
||||
if _, errWrite := writer.Write(input); errWrite != nil {
|
||||
tb.Fatal(errWrite)
|
||||
}
|
||||
if errClose := writer.Close(); errClose != nil {
|
||||
tb.Fatal(errClose)
|
||||
}
|
||||
return output.Bytes()
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Encoding": []string{test.encoding}},
|
||||
Body: io.NopCloser(bytes.NewReader(test.encode(t, []byte(payload)))),
|
||||
}
|
||||
got, errRead := readClaudeOAuthResponseBody(resp)
|
||||
if errRead != nil {
|
||||
t.Fatal(errRead)
|
||||
}
|
||||
if string(got) != payload {
|
||||
t.Fatalf("decoded body = %q, want %q", got, payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
320
backend/internal/auth/claude/oauth_server.go
Normal file
320
backend/internal/auth/claude/oauth_server.go
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
// Package claude provides authentication and token management functionality
|
||||
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Claude API.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// OAuthServer handles the local HTTP server for OAuth callbacks.
|
||||
// It listens for the authorization code response from the OAuth provider
|
||||
// and captures the necessary parameters to complete the authentication flow.
|
||||
type OAuthServer struct {
|
||||
// server is the underlying HTTP server instance
|
||||
server *http.Server
|
||||
// port is the port number on which the server listens
|
||||
port int
|
||||
// resultChan is a channel for sending OAuth results
|
||||
resultChan chan *OAuthResult
|
||||
// errorChan is a channel for sending OAuth errors
|
||||
errorChan chan error
|
||||
// mu is a mutex for protecting server state
|
||||
mu sync.Mutex
|
||||
// running indicates whether the server is currently running
|
||||
running bool
|
||||
}
|
||||
|
||||
// OAuthResult contains the result of the OAuth callback.
|
||||
// It holds either the authorization code and state for successful authentication
|
||||
// or an error message if the authentication failed.
|
||||
type OAuthResult struct {
|
||||
// Code is the authorization code received from the OAuth provider
|
||||
Code string
|
||||
// State is the state parameter used to prevent CSRF attacks
|
||||
State string
|
||||
// Error contains any error message if the OAuth flow failed
|
||||
Error string
|
||||
}
|
||||
|
||||
// NewOAuthServer creates a new OAuth callback server.
|
||||
// It initializes the server with the specified port and creates channels
|
||||
// for handling OAuth results and errors.
|
||||
//
|
||||
// Parameters:
|
||||
// - port: The port number on which the server should listen
|
||||
//
|
||||
// Returns:
|
||||
// - *OAuthServer: A new OAuthServer instance
|
||||
func NewOAuthServer(port int) *OAuthServer {
|
||||
return &OAuthServer{
|
||||
port: port,
|
||||
resultChan: make(chan *OAuthResult, 1),
|
||||
errorChan: make(chan error, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the OAuth callback server.
|
||||
// It sets up the HTTP handlers for the callback and success endpoints,
|
||||
// and begins listening on the specified port.
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the server fails to start
|
||||
func (s *OAuthServer) Start() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.running {
|
||||
return fmt.Errorf("server is already running")
|
||||
}
|
||||
|
||||
// Check if port is available
|
||||
if !s.isPortAvailable() {
|
||||
return fmt.Errorf("port %d is already in use", s.port)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/callback", s.handleCallback)
|
||||
mux.HandleFunc("/success", s.handleSuccess)
|
||||
|
||||
s.server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", s.port),
|
||||
Handler: mux,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
s.running = true
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.errorChan <- fmt.Errorf("server failed to start: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Give server a moment to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the OAuth callback server.
|
||||
// It performs a graceful shutdown of the HTTP server with a timeout.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for controlling the shutdown process
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the server fails to stop gracefully
|
||||
func (s *OAuthServer) Stop(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.running || s.server == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debug("Stopping OAuth callback server")
|
||||
|
||||
// Create a context with timeout for shutdown
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := s.server.Shutdown(shutdownCtx)
|
||||
s.running = false
|
||||
s.server = nil
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// WaitForCallback waits for the OAuth callback with a timeout.
|
||||
// It blocks until either an OAuth result is received, an error occurs,
|
||||
// or the specified timeout is reached.
|
||||
//
|
||||
// Parameters:
|
||||
// - timeout: The maximum time to wait for the callback
|
||||
//
|
||||
// Returns:
|
||||
// - *OAuthResult: The OAuth result if successful
|
||||
// - error: An error if the callback times out or an error occurs
|
||||
func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {
|
||||
select {
|
||||
case result := <-s.resultChan:
|
||||
return result, nil
|
||||
case err := <-s.errorChan:
|
||||
return nil, err
|
||||
case <-time.After(timeout):
|
||||
return nil, fmt.Errorf("timeout waiting for OAuth callback")
|
||||
}
|
||||
}
|
||||
|
||||
// handleCallback handles the OAuth callback endpoint.
|
||||
// It extracts the authorization code and state from the callback URL,
|
||||
// validates the parameters, and sends the result to the waiting channel.
|
||||
//
|
||||
// Parameters:
|
||||
// - w: The HTTP response writer
|
||||
// - r: The HTTP request
|
||||
func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
log.Debug("Received OAuth callback")
|
||||
|
||||
// Validate request method
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract parameters
|
||||
query := r.URL.Query()
|
||||
code := query.Get("code")
|
||||
state := query.Get("state")
|
||||
errorParam := query.Get("error")
|
||||
|
||||
// Validate required parameters
|
||||
if errorParam != "" {
|
||||
log.Errorf("OAuth error received: %s", errorParam)
|
||||
result := &OAuthResult{
|
||||
Error: errorParam,
|
||||
}
|
||||
s.sendResult(result)
|
||||
http.Error(w, fmt.Sprintf("OAuth error: %s", errorParam), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
log.Error("No authorization code received")
|
||||
result := &OAuthResult{
|
||||
Error: "no_code",
|
||||
}
|
||||
s.sendResult(result)
|
||||
http.Error(w, "No authorization code received", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if state == "" {
|
||||
log.Error("No state parameter received")
|
||||
result := &OAuthResult{
|
||||
Error: "no_state",
|
||||
}
|
||||
s.sendResult(result)
|
||||
http.Error(w, "No state parameter received", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Send successful result
|
||||
result := &OAuthResult{
|
||||
Code: code,
|
||||
State: state,
|
||||
}
|
||||
s.sendResult(result)
|
||||
|
||||
// Redirect to success page
|
||||
http.Redirect(w, r, "/success", http.StatusFound)
|
||||
}
|
||||
|
||||
// handleSuccess handles the success page endpoint.
|
||||
// It serves a user-friendly HTML page indicating that authentication was successful.
|
||||
//
|
||||
// Parameters:
|
||||
// - w: The HTTP response writer
|
||||
// - r: The HTTP request
|
||||
func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {
|
||||
log.Debug("Serving success page")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Parse query parameters for customization
|
||||
query := r.URL.Query()
|
||||
setupRequired := query.Get("setup_required") == "true"
|
||||
platformURL := query.Get("platform_url")
|
||||
if platformURL == "" {
|
||||
platformURL = "https://console.anthropic.com/"
|
||||
}
|
||||
|
||||
// Generate success page HTML with dynamic content
|
||||
successHTML := s.generateSuccessHTML(setupRequired, platformURL)
|
||||
|
||||
_, err := w.Write([]byte(successHTML))
|
||||
if err != nil {
|
||||
log.Errorf("Failed to write success page: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// generateSuccessHTML creates the HTML content for the success page.
|
||||
// It customizes the page based on whether additional setup is required
|
||||
// and includes a link to the platform.
|
||||
//
|
||||
// Parameters:
|
||||
// - setupRequired: Whether additional setup is required after authentication
|
||||
// - platformURL: The URL to the platform for additional setup
|
||||
//
|
||||
// Returns:
|
||||
// - string: The HTML content for the success page
|
||||
func (s *OAuthServer) generateSuccessHTML(setupRequired bool, platformURL string) string {
|
||||
html := LoginSuccessHtml
|
||||
|
||||
// Replace platform URL placeholder
|
||||
html = strings.Replace(html, "{{PLATFORM_URL}}", platformURL, -1)
|
||||
|
||||
// Add setup notice if required
|
||||
if setupRequired {
|
||||
setupNotice := strings.Replace(SetupNoticeHtml, "{{PLATFORM_URL}}", platformURL, -1)
|
||||
html = strings.Replace(html, "{{SETUP_NOTICE}}", setupNotice, 1)
|
||||
} else {
|
||||
html = strings.Replace(html, "{{SETUP_NOTICE}}", "", 1)
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// sendResult sends the OAuth result to the waiting channel.
|
||||
// It ensures that the result is sent without blocking the handler.
|
||||
//
|
||||
// Parameters:
|
||||
// - result: The OAuth result to send
|
||||
func (s *OAuthServer) sendResult(result *OAuthResult) {
|
||||
select {
|
||||
case s.resultChan <- result:
|
||||
log.Debug("OAuth result sent to channel")
|
||||
default:
|
||||
log.Warn("OAuth result channel is full, result dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// isPortAvailable checks if the specified port is available.
|
||||
// It attempts to listen on the port to determine availability.
|
||||
//
|
||||
// Returns:
|
||||
// - bool: True if the port is available, false otherwise
|
||||
func (s *OAuthServer) isPortAvailable() bool {
|
||||
addr := fmt.Sprintf(":%d", s.port)
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
_ = listener.Close()
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
// IsRunning returns whether the server is currently running.
|
||||
//
|
||||
// Returns:
|
||||
// - bool: True if the server is running, false otherwise
|
||||
func (s *OAuthServer) IsRunning() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.running
|
||||
}
|
||||
56
backend/internal/auth/claude/pkce.go
Normal file
56
backend/internal/auth/claude/pkce.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Package claude provides authentication and token management functionality
|
||||
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Claude API.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GeneratePKCECodes generates a PKCE code verifier and challenge pair
|
||||
// following RFC 7636 specifications for OAuth 2.0 PKCE extension.
|
||||
// This provides additional security for the OAuth flow by ensuring that
|
||||
// only the client that initiated the request can exchange the authorization code.
|
||||
//
|
||||
// Returns:
|
||||
// - *PKCECodes: A struct containing the code verifier and challenge
|
||||
// - error: An error if the generation fails, nil otherwise
|
||||
func GeneratePKCECodes() (*PKCECodes, error) {
|
||||
// Generate code verifier: 43-128 characters, URL-safe
|
||||
codeVerifier, err := generateCodeVerifier()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate code verifier: %w", err)
|
||||
}
|
||||
|
||||
// Generate code challenge using S256 method
|
||||
codeChallenge := generateCodeChallenge(codeVerifier)
|
||||
|
||||
return &PKCECodes{
|
||||
CodeVerifier: codeVerifier,
|
||||
CodeChallenge: codeChallenge,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateCodeVerifier creates a cryptographically random string
|
||||
// of 128 characters using URL-safe base64 encoding
|
||||
func generateCodeVerifier() (string, error) {
|
||||
// Generate 96 random bytes (will result in 128 base64 characters)
|
||||
bytes := make([]byte, 96)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
|
||||
// Encode to URL-safe base64 without padding
|
||||
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// generateCodeChallenge creates a SHA256 hash of the code verifier
|
||||
// and encodes it using URL-safe base64 encoding without padding
|
||||
func generateCodeChallenge(codeVerifier string) string {
|
||||
hash := sha256.Sum256([]byte(codeVerifier))
|
||||
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
|
||||
}
|
||||
104
backend/internal/auth/claude/token.go
Normal file
104
backend/internal/auth/claude/token.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// Package claude provides authentication and token management functionality
|
||||
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Claude API.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
// 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"`
|
||||
|
||||
// AccountUUID identifies the Anthropic account returned by OAuth.
|
||||
AccountUUID string `json:"account_uuid,omitempty"`
|
||||
|
||||
// OrganizationUUID identifies the Anthropic organization returned by OAuth.
|
||||
OrganizationUUID string `json:"organization_uuid,omitempty"`
|
||||
|
||||
// OrganizationName is the display name returned by OAuth.
|
||||
OrganizationName string `json:"organization_name,omitempty"`
|
||||
|
||||
// DeviceIDs contains the single device identity assigned to this credential.
|
||||
DeviceIDs []string `json:"claude_device_ids,omitempty"`
|
||||
|
||||
// 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"`
|
||||
|
||||
// Metadata holds arbitrary key-value pairs injected via hooks.
|
||||
// It is not exported to JSON directly to allow flattening during serialization.
|
||||
Metadata map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
// SetMetadata allows external callers to inject metadata into the storage before saving.
|
||||
func (ts *ClaudeTokenStorage) SetMetadata(meta map[string]any) {
|
||||
ts.Metadata = meta
|
||||
}
|
||||
|
||||
// SaveTokenToFile serializes the Claude token storage to a JSON file.
|
||||
// This method creates the necessary directory structure and writes the token
|
||||
// data in JSON format to the specified file path for persistent storage.
|
||||
// It merges any injected metadata into the top-level JSON object.
|
||||
//
|
||||
// Parameters:
|
||||
// - authFilePath: The full path where the token file should be saved
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the operation fails, nil otherwise
|
||||
func (ts *ClaudeTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
misc.LogSavingCredentials(authFilePath)
|
||||
ts.Type = "claude"
|
||||
|
||||
// Create directory structure if it doesn't exist
|
||||
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
|
||||
// Merge metadata using helper
|
||||
data, errMerge := misc.MergeMetadata(ts, ts.Metadata)
|
||||
if errMerge != nil {
|
||||
return fmt.Errorf("failed to merge metadata: %w", errMerge)
|
||||
}
|
||||
|
||||
// Create the token file
|
||||
f, err := os.Create(authFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create token file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := f.Close(); errClose != nil {
|
||||
log.Errorf("claude token storage: close token file error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
// Encode and write the token data as JSON
|
||||
if err = json.NewEncoder(f).Encode(data); err != nil {
|
||||
return fmt.Errorf("failed to write token to file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
59
backend/internal/auth/claude/token_test.go
Normal file
59
backend/internal/auth/claude/token_test.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveTokenToFile_PreservesCustomMetadata(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
authFilePath := filepath.Join(tempDir, "claude-test.json")
|
||||
|
||||
storage := &ClaudeTokenStorage{
|
||||
Type: "claude",
|
||||
Email: "user@example.com",
|
||||
AccessToken: "new-claude-access",
|
||||
RefreshToken: "new-claude-refresh",
|
||||
Expire: "2026-12-31T23:59:59Z",
|
||||
LastRefresh: "2026-04-14T12:00:00Z",
|
||||
}
|
||||
storage.SetMetadata(map[string]any{
|
||||
"disabled": false,
|
||||
"prefix": "claude-prefix",
|
||||
"note": "claude custom note",
|
||||
"proxy_url": "http://proxy:8080",
|
||||
"weight": float64(5),
|
||||
})
|
||||
|
||||
if errSave := storage.SaveTokenToFile(authFilePath); errSave != nil {
|
||||
t.Fatalf("SaveTokenToFile() error = %v", errSave)
|
||||
}
|
||||
|
||||
savedRaw, errRead := os.ReadFile(authFilePath)
|
||||
if errRead != nil {
|
||||
t.Fatalf("os.ReadFile error = %v", errRead)
|
||||
}
|
||||
|
||||
var saved map[string]any
|
||||
if errUnmarshal := json.Unmarshal(savedRaw, &saved); errUnmarshal != nil {
|
||||
t.Fatalf("json.Unmarshal error = %v", errUnmarshal)
|
||||
}
|
||||
|
||||
if saved["access_token"] != "new-claude-access" {
|
||||
t.Errorf("access_token = %v, want new-claude-access", saved["access_token"])
|
||||
}
|
||||
if saved["prefix"] != "claude-prefix" {
|
||||
t.Errorf("prefix = %v, want claude-prefix", saved["prefix"])
|
||||
}
|
||||
if saved["note"] != "claude custom note" {
|
||||
t.Errorf("note = %v, want claude custom note", saved["note"])
|
||||
}
|
||||
if saved["proxy_url"] != "http://proxy:8080" {
|
||||
t.Errorf("proxy_url = %v, want http://proxy:8080", saved["proxy_url"])
|
||||
}
|
||||
if saved["weight"] != float64(5) {
|
||||
t.Errorf("weight = %v, want 5", saved["weight"])
|
||||
}
|
||||
}
|
||||
254
backend/internal/auth/claude/utls_transport.go
Normal file
254
backend/internal/auth/claude/utls_transport.go
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tls "github.com/refraction-networking/utls"
|
||||
internalcache "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/httpwire"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
type claudeRefreshHandshakeTimeoutContextKey struct{}
|
||||
|
||||
var claudeOAuthRefreshHeaderOrder = []string{
|
||||
"Accept",
|
||||
"Content-Type",
|
||||
"User-Agent",
|
||||
"Content-Length",
|
||||
"Accept-Encoding",
|
||||
"Host",
|
||||
"Connection",
|
||||
}
|
||||
|
||||
// claudeOAuthInspectHeaderOrder is the order the native client emits for the
|
||||
// authenticated Axios GET lookups on the OAuth control plane, covering both the
|
||||
// account profile and the claude_cli roles companion request.
|
||||
var claudeOAuthInspectHeaderOrder = []string{
|
||||
"Accept",
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"Cache-Control",
|
||||
"User-Agent",
|
||||
"Accept-Encoding",
|
||||
"Host",
|
||||
"Connection",
|
||||
}
|
||||
|
||||
// claudeOAuthInspectTargets are the authenticated control-plane GET paths that
|
||||
// use claudeOAuthInspectHeaderOrder.
|
||||
var claudeOAuthInspectTargets = []string{
|
||||
"/api/oauth/profile",
|
||||
"/api/oauth/claude_cli/roles",
|
||||
}
|
||||
|
||||
func claudeOAuthRequestHeaderOrder(method, requestTarget string) []string {
|
||||
if method == http.MethodGet {
|
||||
for _, target := range claudeOAuthInspectTargets {
|
||||
if strings.HasPrefix(requestTarget, target) {
|
||||
return claudeOAuthInspectHeaderOrder
|
||||
}
|
||||
}
|
||||
}
|
||||
return claudeOAuthRefreshHeaderOrder
|
||||
}
|
||||
|
||||
// claudeOAuthSessionCacheCapacity bounds one proxy's TLS session cache. The
|
||||
// OAuth control plane only talks to platform.claude.com and api.anthropic.com,
|
||||
// so a small cache covers every reachable server.
|
||||
const (
|
||||
claudeOAuthSessionCacheCapacity = 8
|
||||
claudeOAuthProxySessionCacheCapacity = 64
|
||||
)
|
||||
|
||||
// claudeOAuthSessionCaches keys one session cache per effective proxy URL.
|
||||
//
|
||||
// ClaudeAuth is constructed per operation (every refresh and every executor
|
||||
// profile check builds a new one), so a cache owned by the round tripper would
|
||||
// always start empty and never resume. Keying on the proxy instead matches the
|
||||
// inference plane, where the whole round tripper is cached per proxy, and keeps
|
||||
// resumption from crossing proxy boundaries. TLS sessions are scoped to a
|
||||
// server rather than a credential, and connections are already pooled per proxy
|
||||
// on the inference plane, so this adds no new cross-credential linkage.
|
||||
|
||||
var claudeOAuthSessionCaches = internalcache.NewBoundedLRU[string, tls.ClientSessionCache](
|
||||
claudeOAuthProxySessionCacheCapacity,
|
||||
nil,
|
||||
)
|
||||
|
||||
func claudeOAuthSessionCache(proxyURL string) tls.ClientSessionCache {
|
||||
return claudeOAuthSessionCaches.GetOrAdd(proxyURL, func() tls.ClientSessionCache {
|
||||
return tls.NewLRUClientSessionCache(claudeOAuthSessionCacheCapacity)
|
||||
})
|
||||
}
|
||||
|
||||
// newClaudeOAuthTLSConfig builds the uTLS config for one control-plane dial.
|
||||
//
|
||||
// OmitEmptyPsk keeps the pre_shared_key extension silent until a session is
|
||||
// actually cached, so the first ClientHello is byte-identical to the captured
|
||||
// native handshake. PreferSkipResumptionOnNilExtension is defense in depth: for
|
||||
// HelloCustom specs uTLS panics when it wants to resume but the spec lacks the
|
||||
// matching extension, and this degrades that into a skipped resumption.
|
||||
func newClaudeOAuthTLSConfig(host string, sessionCache tls.ClientSessionCache) *tls.Config {
|
||||
return &tls.Config{
|
||||
ServerName: host,
|
||||
ClientSessionCache: sessionCache,
|
||||
OmitEmptyPsk: true,
|
||||
PreferSkipResumptionOnNilExtension: true,
|
||||
}
|
||||
}
|
||||
|
||||
// claudeOAuthTLSClientHelloSpec reproduces the compact Node/OpenSSL profile
|
||||
// Claude Code 2.1.220 uses for Axios OAuth control-plane requests. Unlike the
|
||||
// inference profile, it advertises no ALPN extension and therefore uses
|
||||
// HTTP/1.1 without negotiating a protocol.
|
||||
func claudeOAuthTLSClientHelloSpec() *tls.ClientHelloSpec {
|
||||
return &tls.ClientHelloSpec{
|
||||
TLSVersMin: tls.VersionTLS12,
|
||||
TLSVersMax: tls.VersionTLS13,
|
||||
CompressionMethods: []uint8{0},
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_AES_128_GCM_SHA256,
|
||||
tls.TLS_AES_256_GCM_SHA384,
|
||||
tls.TLS_CHACHA20_POLY1305_SHA256,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
|
||||
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_RSA_WITH_AES_128_CBC_SHA,
|
||||
tls.TLS_RSA_WITH_AES_256_CBC_SHA,
|
||||
},
|
||||
Extensions: []tls.TLSExtension{
|
||||
&tls.SNIExtension{},
|
||||
&tls.ExtendedMasterSecretExtension{},
|
||||
&tls.RenegotiationInfoExtension{Renegotiation: tls.RenegotiateOnceAsClient},
|
||||
&tls.SupportedCurvesExtension{Curves: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384}},
|
||||
&tls.SupportedPointsExtension{SupportedPoints: []byte{0}},
|
||||
&tls.SessionTicketExtension{},
|
||||
&tls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []tls.SignatureScheme{
|
||||
tls.ECDSAWithP256AndSHA256,
|
||||
tls.PSSWithSHA256,
|
||||
tls.PKCS1WithSHA256,
|
||||
tls.ECDSAWithP384AndSHA384,
|
||||
tls.PSSWithSHA384,
|
||||
tls.PKCS1WithSHA384,
|
||||
tls.PSSWithSHA512,
|
||||
tls.PKCS1WithSHA512,
|
||||
tls.PKCS1WithSHA1,
|
||||
}},
|
||||
&tls.KeyShareExtension{KeyShares: []tls.KeyShare{{Group: tls.X25519}}},
|
||||
&tls.PSKKeyExchangeModesExtension{Modes: []uint8{tls.PskModeDHE}},
|
||||
&tls.SupportedVersionsExtension{Versions: []uint16{tls.VersionTLS13, tls.VersionTLS12}},
|
||||
// pre_shared_key MUST be the final extension (RFC 8446 4.2.11). It
|
||||
// contributes zero bytes until a cached session exists.
|
||||
&tls.UtlsPreSharedKeyExtension{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// utlsRoundTripper uses Claude Code's OAuth control-plane TLS and HTTP/1.1
|
||||
// profile while retaining net/http proxy, cancellation, response parsing and
|
||||
// connection lifecycle semantics.
|
||||
type utlsRoundTripper struct {
|
||||
dialer proxy.Dialer
|
||||
// sessionCache is shared by every transport built for the same proxy, so
|
||||
// short-lived ClaudeAuth instances can still resume, while resumption never
|
||||
// crosses proxy boundaries.
|
||||
sessionCache tls.ClientSessionCache
|
||||
transport *http.Transport
|
||||
}
|
||||
|
||||
func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper {
|
||||
var dialer proxy.Dialer = proxy.Direct
|
||||
var proxyURL string
|
||||
if cfg != nil {
|
||||
proxyURL = cfg.ProxyURL
|
||||
proxyDialer, mode, errBuild := proxyutil.BuildDialer(cfg.ProxyURL)
|
||||
if errBuild != nil {
|
||||
log.Errorf("failed to configure proxy dialer for %q: %v", proxyutil.Redact(cfg.ProxyURL), errBuild)
|
||||
} else if mode != proxyutil.ModeInherit && proxyDialer != nil {
|
||||
dialer = proxyDialer
|
||||
}
|
||||
}
|
||||
|
||||
roundTripper := &utlsRoundTripper{
|
||||
dialer: dialer,
|
||||
sessionCache: claudeOAuthSessionCache(proxyURL),
|
||||
}
|
||||
roundTripper.transport = &http.Transport{
|
||||
ForceAttemptHTTP2: false,
|
||||
DialTLSContext: roundTripper.dialTLSContext,
|
||||
}
|
||||
return roundTripper
|
||||
}
|
||||
|
||||
func (t *utlsRoundTripper) dialTLSContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var (
|
||||
conn net.Conn
|
||||
err error
|
||||
)
|
||||
if contextDialer, ok := t.dialer.(proxy.ContextDialer); ok {
|
||||
conn, err = contextDialer.DialContext(ctx, network, addr)
|
||||
} else {
|
||||
conn, err = t.dialer.Dial(network, addr)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claude oauth tls: dial upstream: %w", err)
|
||||
}
|
||||
|
||||
host, _, errSplit := net.SplitHostPort(addr)
|
||||
if errSplit != nil {
|
||||
if errClose := conn.Close(); errClose != nil {
|
||||
log.Debugf("claude oauth tls: close failed connection: %v", errClose)
|
||||
}
|
||||
return nil, fmt.Errorf("claude oauth tls: split upstream address: %w", errSplit)
|
||||
}
|
||||
tlsConn := tls.UClient(conn, newClaudeOAuthTLSConfig(host, t.sessionCache), tls.HelloCustom)
|
||||
if errPreset := tlsConn.ApplyPreset(claudeOAuthTLSClientHelloSpec()); errPreset != nil {
|
||||
if errClose := tlsConn.Close(); errClose != nil {
|
||||
log.Debugf("claude oauth tls: close connection after preset failure: %v", errClose)
|
||||
}
|
||||
return nil, fmt.Errorf("claude oauth tls: apply ClientHello: %w", errPreset)
|
||||
}
|
||||
handshakeCtx := ctx
|
||||
if handshakeTimeout, _ := ctx.Value(claudeRefreshHandshakeTimeoutContextKey{}).(time.Duration); handshakeTimeout > 0 {
|
||||
var cancelHandshake context.CancelFunc
|
||||
handshakeCtx, cancelHandshake = context.WithTimeout(ctx, handshakeTimeout)
|
||||
defer cancelHandshake()
|
||||
}
|
||||
if errHandshake := tlsConn.HandshakeContext(handshakeCtx); errHandshake != nil {
|
||||
if errClose := tlsConn.Close(); errClose != nil {
|
||||
log.Debugf("claude oauth tls: close connection after handshake failure: %v", errClose)
|
||||
}
|
||||
return nil, fmt.Errorf("claude oauth tls: handshake upstream: %w", errHandshake)
|
||||
}
|
||||
return httpwire.NewOrderedRequestConn(tlsConn, claudeOAuthRequestHeaderOrder), nil
|
||||
}
|
||||
|
||||
func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (t *utlsRoundTripper) CloseIdleConnections() {
|
||||
t.transport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
func NewAnthropicHttpClient(cfg *config.SDKConfig) *http.Client {
|
||||
return &http.Client{Transport: newUtlsRoundTripper(cfg)}
|
||||
}
|
||||
284
backend/internal/auth/claude/utls_transport_test.go
Normal file
284
backend/internal/auth/claude/utls_transport_test.go
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tls "github.com/refraction-networking/utls"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
)
|
||||
|
||||
type claudeTestDialer struct {
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
func (d claudeTestDialer) Dial(_, _ string) (net.Conn, error) {
|
||||
return d.conn, nil
|
||||
}
|
||||
|
||||
func TestUtlsRoundTripperBoundsTLSHandshake(t *testing.T) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
defer func() {
|
||||
if errClose := serverConn.Close(); errClose != nil {
|
||||
t.Errorf("server connection close returned error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
transport := &utlsRoundTripper{dialer: claudeTestDialer{conn: clientConn}}
|
||||
ctx := context.WithValue(context.Background(), claudeRefreshHandshakeTimeoutContextKey{}, 20*time.Millisecond)
|
||||
startedAt := time.Now()
|
||||
_, err := transport.dialTLSContext(ctx, "tcp", "example.com:443")
|
||||
if err == nil {
|
||||
t.Fatal("expected TLS handshake timeout")
|
||||
}
|
||||
var netErr net.Error
|
||||
if !errors.As(err, &netErr) || !netErr.Timeout() {
|
||||
t.Fatalf("error = %v, want timeout error", err)
|
||||
}
|
||||
if elapsed := time.Since(startedAt); elapsed > time.Second {
|
||||
t.Fatalf("TLS handshake took %s, want less than one second", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeOAuthTLSClientHelloSpecMatchesNative220Capture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const wantJA3 = "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49161-49171-49162-49172-156-157-47-53,0-23-65281-10-11-35-13-51-45-43,29-23-24,0"
|
||||
const wantJA3MD5 = "203503b7023848ab87b9836c336b8e81"
|
||||
wantCipherSuites := []uint16{4865, 4866, 4867, 49195, 49199, 49196, 49200, 52393, 52392, 49161, 49171, 49162, 49172, 156, 157, 47, 53}
|
||||
wantExtensions := []uint16{0, 23, 65281, 10, 11, 35, 13, 51, 45, 43}
|
||||
|
||||
spec := claudeOAuthTLSClientHelloSpec()
|
||||
if !reflect.DeepEqual(spec.CipherSuites, wantCipherSuites) {
|
||||
t.Fatalf("cipher suites = %v, want %v", spec.CipherSuites, wantCipherSuites)
|
||||
}
|
||||
extensionTypes := claudeOAuthExtensionTypes(t, spec.Extensions)
|
||||
if !reflect.DeepEqual(extensionTypes, wantExtensions) {
|
||||
t.Fatalf("extension types = %v, want %v", extensionTypes, wantExtensions)
|
||||
}
|
||||
curves := spec.Extensions[3].(*tls.SupportedCurvesExtension).Curves
|
||||
points := spec.Extensions[4].(*tls.SupportedPointsExtension).SupportedPoints
|
||||
actualJA3 := "771," + joinClaudeOAuthUint16(spec.CipherSuites) + "," + joinClaudeOAuthUint16(extensionTypes) + "," + joinClaudeOAuthCurves(curves) + "," + joinClaudeOAuthUint8(points)
|
||||
if actualJA3 != wantJA3 {
|
||||
t.Fatalf("JA3 = %q, want %q", actualJA3, wantJA3)
|
||||
}
|
||||
if strings.Contains(actualJA3, "-16-") {
|
||||
t.Fatal("OAuth JA3 unexpectedly contains ALPN extension 16")
|
||||
}
|
||||
hash := md5.Sum([]byte(actualJA3)) // #nosec G401 -- JA3 requires MD5.
|
||||
if got := hex.EncodeToString(hash[:]); got != wantJA3MD5 {
|
||||
t.Fatalf("JA3 MD5 = %s, want %s", got, wantJA3MD5)
|
||||
}
|
||||
|
||||
record := captureClaudeOAuthClientHello(t)
|
||||
if got := len(record) - 9; got != 245 {
|
||||
t.Fatalf("ClientHello length = %d, want 245", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeOAuthTLSResumptionIsWireSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// RFC 8446 4.2.11 requires pre_shared_key to be the final extension.
|
||||
spec := claudeOAuthTLSClientHelloSpec()
|
||||
last := spec.Extensions[len(spec.Extensions)-1]
|
||||
if _, ok := last.(*tls.UtlsPreSharedKeyExtension); !ok {
|
||||
t.Fatalf("last OAuth extension = %T, want *tls.UtlsPreSharedKeyExtension", last)
|
||||
}
|
||||
|
||||
// Without OmitEmptyPsk uTLS refuses to marshal an empty PSK, and without
|
||||
// PreferSkipResumptionOnNilExtension a HelloCustom resumption attempt panics.
|
||||
cfg := newClaudeOAuthTLSConfig("api.anthropic.com", tls.NewLRUClientSessionCache(claudeOAuthSessionCacheCapacity))
|
||||
if cfg.ServerName != "api.anthropic.com" {
|
||||
t.Fatalf("ServerName = %q, want api.anthropic.com", cfg.ServerName)
|
||||
}
|
||||
if cfg.ClientSessionCache == nil {
|
||||
t.Fatal("ClientSessionCache = nil, want a session cache so resumption is possible")
|
||||
}
|
||||
if !cfg.OmitEmptyPsk {
|
||||
t.Fatal("OmitEmptyPsk = false, want true so an unresumed ClientHello stays byte-identical")
|
||||
}
|
||||
if !cfg.PreferSkipResumptionOnNilExtension {
|
||||
t.Fatal("PreferSkipResumptionOnNilExtension = false, want true to avoid a HelloCustom resumption panic")
|
||||
}
|
||||
|
||||
// ClaudeAuth is rebuilt for every refresh and every executor profile check, so
|
||||
// the cache must be keyed on the proxy rather than owned by the transport;
|
||||
// otherwise every dial starts with an empty cache and never resumes.
|
||||
first := newUtlsRoundTripper(&sdkconfig.SDKConfig{ProxyURL: "http://127.0.0.1:9"})
|
||||
second := newUtlsRoundTripper(&sdkconfig.SDKConfig{ProxyURL: "http://127.0.0.1:9"})
|
||||
if first.sessionCache == nil || second.sessionCache == nil {
|
||||
t.Fatal("round tripper session cache = nil, want a shared per-proxy cache")
|
||||
}
|
||||
if first.sessionCache != second.sessionCache {
|
||||
t.Fatal("same-proxy transports have different session caches, so resumption can never hit")
|
||||
}
|
||||
|
||||
// Resumption must not cross proxy boundaries.
|
||||
other := newUtlsRoundTripper(&sdkconfig.SDKConfig{ProxyURL: "http://127.0.0.1:10"})
|
||||
if first.sessionCache == other.sessionCache {
|
||||
t.Fatal("different proxies share a session cache, want per-proxy isolation")
|
||||
}
|
||||
|
||||
// Same check through the real entry point: two ClaudeAuth values built the way
|
||||
// refresh and the executor profile check build them must still share a cache.
|
||||
cacheOf := func(service *ClaudeAuth) tls.ClientSessionCache {
|
||||
t.Helper()
|
||||
transport, ok := service.httpClient.Transport.(*utlsRoundTripper)
|
||||
if !ok {
|
||||
t.Fatalf("ClaudeAuth transport type = %T, want *utlsRoundTripper", service.httpClient.Transport)
|
||||
}
|
||||
return transport.sessionCache
|
||||
}
|
||||
if cacheOf(NewClaudeAuthWithProxyURL(nil, "http://127.0.0.1:11")) != cacheOf(NewClaudeAuthWithProxyURL(nil, "http://127.0.0.1:11")) {
|
||||
t.Fatal("per-operation ClaudeAuth instances do not share a session cache, so refresh can never resume")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeOAuthSessionCacheBoundsProxyCardinality(t *testing.T) {
|
||||
firstProxy := "http://127.0.0.1:31000"
|
||||
first := claudeOAuthSessionCache(firstProxy)
|
||||
for index := 1; index <= claudeOAuthProxySessionCacheCapacity; index++ {
|
||||
claudeOAuthSessionCache("http://127.0.0.1:" + strconv.Itoa(31000+index))
|
||||
}
|
||||
if got := claudeOAuthSessionCaches.Len(); got > claudeOAuthProxySessionCacheCapacity {
|
||||
t.Fatalf("OAuth session caches = %d, want at most %d", got, claudeOAuthProxySessionCacheCapacity)
|
||||
}
|
||||
if recreated := claudeOAuthSessionCache(firstProxy); recreated == first {
|
||||
t.Fatal("least recently used OAuth proxy session cache was not evicted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeOAuthRequestHeaderOrderMatchesNative220Capture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRefresh := []string{"Accept", "Content-Type", "User-Agent", "Content-Length", "Accept-Encoding", "Host", "Connection"}
|
||||
wantProfile := []string{"Accept", "Content-Type", "Authorization", "Cache-Control", "User-Agent", "Accept-Encoding", "Host", "Connection"}
|
||||
if got := claudeOAuthRequestHeaderOrder("POST", "/v1/oauth/token"); !reflect.DeepEqual(got, wantRefresh) {
|
||||
t.Fatalf("refresh header order = %v, want %v", got, wantRefresh)
|
||||
}
|
||||
if got := claudeOAuthRequestHeaderOrder("GET", "/api/oauth/profile"); !reflect.DeepEqual(got, wantProfile) {
|
||||
t.Fatalf("profile header order = %v, want %v", got, wantProfile)
|
||||
}
|
||||
// The claude_cli roles companion lookup uses the same authenticated Axios GET shape.
|
||||
if got := claudeOAuthRequestHeaderOrder("GET", "/api/oauth/claude_cli/roles"); !reflect.DeepEqual(got, wantProfile) {
|
||||
t.Fatalf("roles header order = %v, want %v", got, wantProfile)
|
||||
}
|
||||
// The authorization-code exchange is a POST and keeps the JSON-body order.
|
||||
if got := claudeOAuthRequestHeaderOrder("POST", "/api/oauth/profile"); !reflect.DeepEqual(got, wantRefresh) {
|
||||
t.Fatalf("non-GET profile target header order = %v, want %v", got, wantRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
func claudeOAuthExtensionTypes(t *testing.T, extensions []tls.TLSExtension) []uint16 {
|
||||
t.Helper()
|
||||
result := make([]uint16, 0, len(extensions))
|
||||
for _, extension := range extensions {
|
||||
switch extension.(type) {
|
||||
case *tls.SNIExtension:
|
||||
result = append(result, 0)
|
||||
case *tls.ExtendedMasterSecretExtension:
|
||||
result = append(result, 23)
|
||||
case *tls.RenegotiationInfoExtension:
|
||||
result = append(result, 65281)
|
||||
case *tls.SupportedCurvesExtension:
|
||||
result = append(result, 10)
|
||||
case *tls.SupportedPointsExtension:
|
||||
result = append(result, 11)
|
||||
case *tls.SessionTicketExtension:
|
||||
result = append(result, 35)
|
||||
case *tls.SignatureAlgorithmsExtension:
|
||||
result = append(result, 13)
|
||||
case *tls.KeyShareExtension:
|
||||
result = append(result, 51)
|
||||
case *tls.PSKKeyExchangeModesExtension:
|
||||
result = append(result, 45)
|
||||
case *tls.SupportedVersionsExtension:
|
||||
result = append(result, 43)
|
||||
case *tls.UtlsPreSharedKeyExtension:
|
||||
// pre_shared_key contributes zero bytes until a session is cached, so
|
||||
// it never appears in the fresh ClientHello the native capture covers
|
||||
// and must stay out of the JA3 extension list. The record length
|
||||
// assertion in the caller proves the byte neutrality.
|
||||
continue
|
||||
default:
|
||||
t.Fatalf("unexpected OAuth TLS extension %T", extension)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func joinClaudeOAuthUint16(values []uint16) string {
|
||||
parts := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
parts[index] = strconv.Itoa(int(value))
|
||||
}
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
func joinClaudeOAuthCurves(values []tls.CurveID) string {
|
||||
parts := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
parts[index] = strconv.Itoa(int(value))
|
||||
}
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
func joinClaudeOAuthUint8(values []uint8) string {
|
||||
parts := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
parts[index] = strconv.Itoa(int(value))
|
||||
}
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
func captureClaudeOAuthClientHello(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
if errClose := clientConn.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
||||
t.Errorf("close client connection: %v", errClose)
|
||||
}
|
||||
if errClose := serverConn.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
||||
t.Errorf("close server connection: %v", errClose)
|
||||
}
|
||||
})
|
||||
// Use the production config so the captured bytes reflect the real dial path.
|
||||
cfg := newClaudeOAuthTLSConfig("api.anthropic.com", tls.NewLRUClientSessionCache(claudeOAuthSessionCacheCapacity))
|
||||
tlsConn := tls.UClient(clientConn, cfg, tls.HelloCustom)
|
||||
if errPreset := tlsConn.ApplyPreset(claudeOAuthTLSClientHelloSpec()); errPreset != nil {
|
||||
t.Fatal(errPreset)
|
||||
}
|
||||
handshakeDone := make(chan error, 1)
|
||||
go func() { handshakeDone <- tlsConn.Handshake() }()
|
||||
if errDeadline := serverConn.SetReadDeadline(time.Now().Add(5 * time.Second)); errDeadline != nil {
|
||||
t.Fatal(errDeadline)
|
||||
}
|
||||
header := make([]byte, 5)
|
||||
if _, errRead := io.ReadFull(serverConn, header); errRead != nil {
|
||||
t.Fatal(errRead)
|
||||
}
|
||||
payload := make([]byte, int(binary.BigEndian.Uint16(header[3:5])))
|
||||
if _, errRead := io.ReadFull(serverConn, payload); errRead != nil {
|
||||
t.Fatal(errRead)
|
||||
}
|
||||
if errClose := serverConn.Close(); errClose != nil {
|
||||
t.Fatal(errClose)
|
||||
}
|
||||
select {
|
||||
case <-handshakeDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("OAuth uTLS handshake did not exit")
|
||||
}
|
||||
return append(header, payload...)
|
||||
}
|
||||
171
backend/internal/auth/codex/errors.go
Normal file
171
backend/internal/auth/codex/errors.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package codex
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// OAuthError represents an OAuth-specific error.
|
||||
type OAuthError struct {
|
||||
// Code is the OAuth error code.
|
||||
Code string `json:"error"`
|
||||
// Description is a human-readable description of the error.
|
||||
Description string `json:"error_description,omitempty"`
|
||||
// URI is a URI identifying a human-readable web page with information about the error.
|
||||
URI string `json:"error_uri,omitempty"`
|
||||
// StatusCode is the HTTP status code associated with the error.
|
||||
StatusCode int `json:"-"`
|
||||
}
|
||||
|
||||
// Error returns a string representation of the OAuth error.
|
||||
func (e *OAuthError) Error() string {
|
||||
if e.Description != "" {
|
||||
return fmt.Sprintf("OAuth error %s: %s", e.Code, e.Description)
|
||||
}
|
||||
return fmt.Sprintf("OAuth error: %s", e.Code)
|
||||
}
|
||||
|
||||
// NewOAuthError creates a new OAuth error with the specified code, description, and status code.
|
||||
func NewOAuthError(code, description string, statusCode int) *OAuthError {
|
||||
return &OAuthError{
|
||||
Code: code,
|
||||
Description: description,
|
||||
StatusCode: statusCode,
|
||||
}
|
||||
}
|
||||
|
||||
// AuthenticationError represents authentication-related errors.
|
||||
type AuthenticationError struct {
|
||||
// Type is the type of authentication error.
|
||||
Type string `json:"type"`
|
||||
// Message is a human-readable message describing the error.
|
||||
Message string `json:"message"`
|
||||
// Code is the HTTP status code associated with the error.
|
||||
Code int `json:"code"`
|
||||
// Cause is the underlying error that caused this authentication error.
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Error returns a string representation of the authentication error.
|
||||
func (e *AuthenticationError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("%s: %s (caused by: %v)", e.Type, e.Message, e.Cause)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Type, e.Message)
|
||||
}
|
||||
|
||||
// Common authentication error types.
|
||||
var (
|
||||
// ErrTokenExpired = &AuthenticationError{
|
||||
// Type: "token_expired",
|
||||
// Message: "Access token has expired",
|
||||
// Code: http.StatusUnauthorized,
|
||||
// }
|
||||
|
||||
// ErrInvalidState represents an error for invalid OAuth state parameter.
|
||||
ErrInvalidState = &AuthenticationError{
|
||||
Type: "invalid_state",
|
||||
Message: "OAuth state parameter is invalid",
|
||||
Code: http.StatusBadRequest,
|
||||
}
|
||||
|
||||
// ErrCodeExchangeFailed represents an error when exchanging authorization code for tokens fails.
|
||||
ErrCodeExchangeFailed = &AuthenticationError{
|
||||
Type: "code_exchange_failed",
|
||||
Message: "Failed to exchange authorization code for tokens",
|
||||
Code: http.StatusBadRequest,
|
||||
}
|
||||
|
||||
// ErrServerStartFailed represents an error when starting the OAuth callback server fails.
|
||||
ErrServerStartFailed = &AuthenticationError{
|
||||
Type: "server_start_failed",
|
||||
Message: "Failed to start OAuth callback server",
|
||||
Code: http.StatusInternalServerError,
|
||||
}
|
||||
|
||||
// ErrPortInUse represents an error when the OAuth callback port is already in use.
|
||||
ErrPortInUse = &AuthenticationError{
|
||||
Type: "port_in_use",
|
||||
Message: "OAuth callback port is already in use",
|
||||
Code: 13, // Special exit code for port-in-use
|
||||
}
|
||||
|
||||
// ErrCallbackTimeout represents an error when waiting for OAuth callback times out.
|
||||
ErrCallbackTimeout = &AuthenticationError{
|
||||
Type: "callback_timeout",
|
||||
Message: "Timeout waiting for OAuth callback",
|
||||
Code: http.StatusRequestTimeout,
|
||||
}
|
||||
|
||||
// ErrBrowserOpenFailed represents an error when opening the browser for authentication fails.
|
||||
ErrBrowserOpenFailed = &AuthenticationError{
|
||||
Type: "browser_open_failed",
|
||||
Message: "Failed to open browser for authentication",
|
||||
Code: http.StatusInternalServerError,
|
||||
}
|
||||
)
|
||||
|
||||
// NewAuthenticationError creates a new authentication error with a cause based on a base error.
|
||||
func NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError {
|
||||
return &AuthenticationError{
|
||||
Type: baseErr.Type,
|
||||
Message: baseErr.Message,
|
||||
Code: baseErr.Code,
|
||||
Cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
// IsAuthenticationError checks if an error is an authentication error.
|
||||
func IsAuthenticationError(err error) bool {
|
||||
var authenticationError *AuthenticationError
|
||||
ok := errors.As(err, &authenticationError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsOAuthError checks if an error is an OAuth error.
|
||||
func IsOAuthError(err error) bool {
|
||||
var oAuthError *OAuthError
|
||||
ok := errors.As(err, &oAuthError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetUserFriendlyMessage returns a user-friendly error message based on the error type.
|
||||
func GetUserFriendlyMessage(err error) string {
|
||||
switch {
|
||||
case IsAuthenticationError(err):
|
||||
var authErr *AuthenticationError
|
||||
errors.As(err, &authErr)
|
||||
switch authErr.Type {
|
||||
case "token_expired":
|
||||
return "Your authentication has expired. Please log in again."
|
||||
case "token_invalid":
|
||||
return "Your authentication is invalid. Please log in again."
|
||||
case "authentication_required":
|
||||
return "Please log in to continue."
|
||||
case "port_in_use":
|
||||
return "The required port is already in use. Please close any applications using port 3000 and try again."
|
||||
case "callback_timeout":
|
||||
return "Authentication timed out. Please try again."
|
||||
case "browser_open_failed":
|
||||
return "Could not open your browser automatically. Please copy and paste the URL manually."
|
||||
default:
|
||||
return "Authentication failed. Please try again."
|
||||
}
|
||||
case IsOAuthError(err):
|
||||
var oauthErr *OAuthError
|
||||
errors.As(err, &oauthErr)
|
||||
switch oauthErr.Code {
|
||||
case "access_denied":
|
||||
return "Authentication was cancelled or denied."
|
||||
case "invalid_request":
|
||||
return "Invalid authentication request. Please try again."
|
||||
case "server_error":
|
||||
return "Authentication server error. Please try again later."
|
||||
default:
|
||||
return fmt.Sprintf("Authentication failed: %s", oauthErr.Description)
|
||||
}
|
||||
default:
|
||||
return "An unexpected error occurred. Please try again."
|
||||
}
|
||||
}
|
||||
51
backend/internal/auth/codex/filename.go
Normal file
51
backend/internal/auth/codex/filename.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package codex
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// CredentialFileName returns the filename used to persist Codex OAuth credentials.
|
||||
// The account hash is included when available to keep accounts with the same email
|
||||
// and plan distinct. The legacy email-based format remains the fallback.
|
||||
func CredentialFileName(email, planType, hashAccountID string, includeProviderPrefix bool) string {
|
||||
email = strings.TrimSpace(email)
|
||||
plan := normalizePlanTypeForFilename(planType)
|
||||
hashAccountID = strings.TrimSpace(hashAccountID)
|
||||
|
||||
prefix := ""
|
||||
if includeProviderPrefix {
|
||||
prefix = "codex"
|
||||
}
|
||||
|
||||
if hashAccountID != "" {
|
||||
if plan == "" {
|
||||
return fmt.Sprintf("%s-%s-%s.json", prefix, hashAccountID, email)
|
||||
}
|
||||
return fmt.Sprintf("%s-%s-%s-%s.json", prefix, hashAccountID, email, plan)
|
||||
}
|
||||
if plan == "" {
|
||||
return fmt.Sprintf("%s-%s.json", prefix, email)
|
||||
}
|
||||
return fmt.Sprintf("%s-%s-%s.json", prefix, email, plan)
|
||||
}
|
||||
|
||||
func normalizePlanTypeForFilename(planType string) string {
|
||||
planType = strings.TrimSpace(planType)
|
||||
if planType == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.FieldsFunc(planType, func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
for i, part := range parts {
|
||||
parts[i] = strings.ToLower(strings.TrimSpace(part))
|
||||
}
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
88
backend/internal/auth/codex/filename_test.go
Normal file
88
backend/internal/auth/codex/filename_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package codex
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCredentialFileName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
planType string
|
||||
hashAccountID string
|
||||
includeProviderPrefix bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "team includes account hash",
|
||||
email: "user@example.com",
|
||||
planType: "team",
|
||||
hashAccountID: "abc12345",
|
||||
includeProviderPrefix: true,
|
||||
want: "codex-abc12345-user@example.com-team.json",
|
||||
},
|
||||
{
|
||||
name: "k12 includes account hash",
|
||||
email: "user@example.com",
|
||||
planType: "k12",
|
||||
hashAccountID: "def67890",
|
||||
includeProviderPrefix: true,
|
||||
want: "codex-def67890-user@example.com-k12.json",
|
||||
},
|
||||
{
|
||||
name: "k12 without account hash falls back to email and plan",
|
||||
email: "user@example.com",
|
||||
planType: "k12",
|
||||
hashAccountID: "",
|
||||
includeProviderPrefix: true,
|
||||
want: "codex-user@example.com-k12.json",
|
||||
},
|
||||
{
|
||||
name: "plus includes account hash",
|
||||
email: " user@example.com ",
|
||||
planType: "Plus",
|
||||
hashAccountID: " abc12345 ",
|
||||
includeProviderPrefix: true,
|
||||
want: "codex-abc12345-user@example.com-plus.json",
|
||||
},
|
||||
{
|
||||
name: "plus without account hash falls back to email and plan",
|
||||
email: "user@example.com",
|
||||
planType: "plus",
|
||||
hashAccountID: "",
|
||||
includeProviderPrefix: true,
|
||||
want: "codex-user@example.com-plus.json",
|
||||
},
|
||||
{
|
||||
name: "plan is normalized",
|
||||
email: "user@example.com",
|
||||
planType: " Team Plan ",
|
||||
hashAccountID: "abc12345",
|
||||
includeProviderPrefix: true,
|
||||
want: "codex-abc12345-user@example.com-team-plan.json",
|
||||
},
|
||||
{
|
||||
name: "account hash is used without plan",
|
||||
email: "user@example.com",
|
||||
planType: "",
|
||||
hashAccountID: "abc12345",
|
||||
includeProviderPrefix: true,
|
||||
want: "codex-abc12345-user@example.com.json",
|
||||
},
|
||||
{
|
||||
name: "missing plan and account hash falls back to email",
|
||||
email: "user@example.com",
|
||||
planType: "",
|
||||
hashAccountID: "",
|
||||
includeProviderPrefix: true,
|
||||
want: "codex-user@example.com.json",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := CredentialFileName(tt.email, tt.planType, tt.hashAccountID, tt.includeProviderPrefix)
|
||||
if got != tt.want {
|
||||
t.Fatalf("CredentialFileName() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
214
backend/internal/auth/codex/html_templates.go
Normal file
214
backend/internal/auth/codex/html_templates.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
package codex
|
||||
|
||||
// LoginSuccessHTML is the HTML template for the page shown after a successful
|
||||
// OAuth2 authentication with Codex. It informs the user that the authentication
|
||||
// was successful and provides a countdown timer to automatically close the window.
|
||||
const LoginSuccessHtml = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Authentication Successful - Codex</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2310b981'%3E%3Cpath d='M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
background: white;
|
||||
padding: 2.5rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
h1 {
|
||||
color: #1f2937;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.setup-notice {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.setup-notice h3 {
|
||||
color: #92400e;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.setup-notice p {
|
||||
color: #92400e;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.setup-notice a {
|
||||
color: #1d4ed8;
|
||||
text-decoration: none;
|
||||
}
|
||||
.setup-notice a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.button-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
.button-primary:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.button-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
.button-secondary:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.countdown {
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.footer a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="success-icon">✓</div>
|
||||
<h1>Authentication Successful!</h1>
|
||||
<p class="subtitle">You have successfully authenticated with Codex. You can now close this window and return to your terminal to continue.</p>
|
||||
|
||||
{{SETUP_NOTICE}}
|
||||
|
||||
<div class="actions">
|
||||
<button class="button button-primary" onclick="window.close()">
|
||||
<span>Close Window</span>
|
||||
</button>
|
||||
<a href="{{PLATFORM_URL}}" target="_blank" class="button button-secondary">
|
||||
<span>Open Platform</span>
|
||||
<span>↗</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="countdown">
|
||||
This window will close automatically in <span id="countdown">10</span> seconds
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Powered by <a href="https://chatgpt.com" target="_blank">ChatGPT</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let countdown = 10;
|
||||
const countdownElement = document.getElementById('countdown');
|
||||
|
||||
const timer = setInterval(() => {
|
||||
countdown--;
|
||||
countdownElement.textContent = countdown;
|
||||
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
window.close();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Close window when user presses Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus the close button for keyboard accessibility
|
||||
document.querySelector('.button-primary').focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// SetupNoticeHTML is the HTML template for the section that provides instructions
|
||||
// for additional setup. This is displayed on the success page when further actions
|
||||
// are required from the user.
|
||||
const SetupNoticeHtml = `
|
||||
<div class="setup-notice">
|
||||
<h3>Additional Setup Required</h3>
|
||||
<p>To complete your setup, please visit the <a href="{{PLATFORM_URL}}" target="_blank">Codex</a> to configure your account.</p>
|
||||
</div>`
|
||||
102
backend/internal/auth/codex/jwt_parser.go
Normal file
102
backend/internal/auth/codex/jwt_parser.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package codex
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JWTClaims represents the claims section of a JSON Web Token (JWT).
|
||||
// It includes standard claims like issuer, subject, and expiration time, as well as
|
||||
// custom claims specific to OpenAI's authentication.
|
||||
type JWTClaims struct {
|
||||
AtHash string `json:"at_hash"`
|
||||
Aud []string `json:"aud"`
|
||||
AuthProvider string `json:"auth_provider"`
|
||||
AuthTime int `json:"auth_time"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Exp int `json:"exp"`
|
||||
CodexAuthInfo CodexAuthInfo `json:"https://api.openai.com/auth"`
|
||||
Iat int `json:"iat"`
|
||||
Iss string `json:"iss"`
|
||||
Jti string `json:"jti"`
|
||||
Rat int `json:"rat"`
|
||||
Sid string `json:"sid"`
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
|
||||
// Organizations defines the structure for organization details within the JWT claims.
|
||||
// It holds information about the user's organization, such as ID, role, and title.
|
||||
type Organizations struct {
|
||||
ID string `json:"id"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
Role string `json:"role"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// CodexAuthInfo contains authentication-related details specific to Codex.
|
||||
// This includes ChatGPT account information, subscription status, and user/organization IDs.
|
||||
type CodexAuthInfo struct {
|
||||
ChatgptAccountID string `json:"chatgpt_account_id"`
|
||||
ChatgptPlanType string `json:"chatgpt_plan_type"`
|
||||
ChatgptSubscriptionActiveStart any `json:"chatgpt_subscription_active_start"`
|
||||
ChatgptSubscriptionActiveUntil any `json:"chatgpt_subscription_active_until"`
|
||||
ChatgptSubscriptionLastChecked time.Time `json:"chatgpt_subscription_last_checked"`
|
||||
ChatgptUserID string `json:"chatgpt_user_id"`
|
||||
Groups []any `json:"groups"`
|
||||
Organizations []Organizations `json:"organizations"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// ParseJWTToken parses a JWT token string and extracts its claims without performing
|
||||
// cryptographic signature verification. This is useful for introspecting the token's
|
||||
// contents to retrieve user information from an ID token after it has been validated
|
||||
// by the authentication server.
|
||||
func ParseJWTToken(token string) (*JWTClaims, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid JWT token format: expected 3 parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
// Decode the claims (payload) part
|
||||
claimsData, err := base64URLDecode(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode JWT claims: %w", err)
|
||||
}
|
||||
|
||||
var claims JWTClaims
|
||||
if err = json.Unmarshal(claimsData, &claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal JWT claims: %w", err)
|
||||
}
|
||||
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
// base64URLDecode decodes a Base64 URL-encoded string, adding padding if necessary.
|
||||
// JWTs use a URL-safe Base64 alphabet and omit padding, so this function ensures
|
||||
// correct decoding by re-adding the padding before decoding.
|
||||
func base64URLDecode(data string) ([]byte, error) {
|
||||
// Add padding if necessary
|
||||
switch len(data) % 4 {
|
||||
case 2:
|
||||
data += "=="
|
||||
case 3:
|
||||
data += "="
|
||||
}
|
||||
|
||||
return base64.URLEncoding.DecodeString(data)
|
||||
}
|
||||
|
||||
// GetUserEmail extracts the user's email address from the JWT claims.
|
||||
func (c *JWTClaims) GetUserEmail() string {
|
||||
return c.Email
|
||||
}
|
||||
|
||||
// GetAccountID extracts the user's account ID (subject) from the JWT claims.
|
||||
// It retrieves the unique identifier for the user's ChatGPT account.
|
||||
func (c *JWTClaims) GetAccountID() string {
|
||||
return c.CodexAuthInfo.ChatgptAccountID
|
||||
}
|
||||
317
backend/internal/auth/codex/oauth_server.go
Normal file
317
backend/internal/auth/codex/oauth_server.go
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
package codex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// OAuthServer handles the local HTTP server for OAuth callbacks.
|
||||
// It listens for the authorization code response from the OAuth provider
|
||||
// and captures the necessary parameters to complete the authentication flow.
|
||||
type OAuthServer struct {
|
||||
// server is the underlying HTTP server instance
|
||||
server *http.Server
|
||||
// port is the port number on which the server listens
|
||||
port int
|
||||
// resultChan is a channel for sending OAuth results
|
||||
resultChan chan *OAuthResult
|
||||
// errorChan is a channel for sending OAuth errors
|
||||
errorChan chan error
|
||||
// mu is a mutex for protecting server state
|
||||
mu sync.Mutex
|
||||
// running indicates whether the server is currently running
|
||||
running bool
|
||||
}
|
||||
|
||||
// OAuthResult contains the result of the OAuth callback.
|
||||
// It holds either the authorization code and state for successful authentication
|
||||
// or an error message if the authentication failed.
|
||||
type OAuthResult struct {
|
||||
// Code is the authorization code received from the OAuth provider
|
||||
Code string
|
||||
// State is the state parameter used to prevent CSRF attacks
|
||||
State string
|
||||
// Error contains any error message if the OAuth flow failed
|
||||
Error string
|
||||
}
|
||||
|
||||
// NewOAuthServer creates a new OAuth callback server.
|
||||
// It initializes the server with the specified port and creates channels
|
||||
// for handling OAuth results and errors.
|
||||
//
|
||||
// Parameters:
|
||||
// - port: The port number on which the server should listen
|
||||
//
|
||||
// Returns:
|
||||
// - *OAuthServer: A new OAuthServer instance
|
||||
func NewOAuthServer(port int) *OAuthServer {
|
||||
return &OAuthServer{
|
||||
port: port,
|
||||
resultChan: make(chan *OAuthResult, 1),
|
||||
errorChan: make(chan error, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the OAuth callback server.
|
||||
// It sets up the HTTP handlers for the callback and success endpoints,
|
||||
// and begins listening on the specified port.
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the server fails to start
|
||||
func (s *OAuthServer) Start() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.running {
|
||||
return fmt.Errorf("server is already running")
|
||||
}
|
||||
|
||||
// Check if port is available
|
||||
if !s.isPortAvailable() {
|
||||
return fmt.Errorf("port %d is already in use", s.port)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/auth/callback", s.handleCallback)
|
||||
mux.HandleFunc("/success", s.handleSuccess)
|
||||
|
||||
s.server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", s.port),
|
||||
Handler: mux,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
s.running = true
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.errorChan <- fmt.Errorf("server failed to start: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Give server a moment to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the OAuth callback server.
|
||||
// It performs a graceful shutdown of the HTTP server with a timeout.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for controlling the shutdown process
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the server fails to stop gracefully
|
||||
func (s *OAuthServer) Stop(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.running || s.server == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debug("Stopping OAuth callback server")
|
||||
|
||||
// Create a context with timeout for shutdown
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := s.server.Shutdown(shutdownCtx)
|
||||
s.running = false
|
||||
s.server = nil
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// WaitForCallback waits for the OAuth callback with a timeout.
|
||||
// It blocks until either an OAuth result is received, an error occurs,
|
||||
// or the specified timeout is reached.
|
||||
//
|
||||
// Parameters:
|
||||
// - timeout: The maximum time to wait for the callback
|
||||
//
|
||||
// Returns:
|
||||
// - *OAuthResult: The OAuth result if successful
|
||||
// - error: An error if the callback times out or an error occurs
|
||||
func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {
|
||||
select {
|
||||
case result := <-s.resultChan:
|
||||
return result, nil
|
||||
case err := <-s.errorChan:
|
||||
return nil, err
|
||||
case <-time.After(timeout):
|
||||
return nil, fmt.Errorf("timeout waiting for OAuth callback")
|
||||
}
|
||||
}
|
||||
|
||||
// handleCallback handles the OAuth callback endpoint.
|
||||
// It extracts the authorization code and state from the callback URL,
|
||||
// validates the parameters, and sends the result to the waiting channel.
|
||||
//
|
||||
// Parameters:
|
||||
// - w: The HTTP response writer
|
||||
// - r: The HTTP request
|
||||
func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
log.Debug("Received OAuth callback")
|
||||
|
||||
// Validate request method
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract parameters
|
||||
query := r.URL.Query()
|
||||
code := query.Get("code")
|
||||
state := query.Get("state")
|
||||
errorParam := query.Get("error")
|
||||
|
||||
// Validate required parameters
|
||||
if errorParam != "" {
|
||||
log.Errorf("OAuth error received: %s", errorParam)
|
||||
result := &OAuthResult{
|
||||
Error: errorParam,
|
||||
}
|
||||
s.sendResult(result)
|
||||
http.Error(w, fmt.Sprintf("OAuth error: %s", errorParam), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
log.Error("No authorization code received")
|
||||
result := &OAuthResult{
|
||||
Error: "no_code",
|
||||
}
|
||||
s.sendResult(result)
|
||||
http.Error(w, "No authorization code received", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if state == "" {
|
||||
log.Error("No state parameter received")
|
||||
result := &OAuthResult{
|
||||
Error: "no_state",
|
||||
}
|
||||
s.sendResult(result)
|
||||
http.Error(w, "No state parameter received", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Send successful result
|
||||
result := &OAuthResult{
|
||||
Code: code,
|
||||
State: state,
|
||||
}
|
||||
s.sendResult(result)
|
||||
|
||||
// Redirect to success page
|
||||
http.Redirect(w, r, "/success", http.StatusFound)
|
||||
}
|
||||
|
||||
// handleSuccess handles the success page endpoint.
|
||||
// It serves a user-friendly HTML page indicating that authentication was successful.
|
||||
//
|
||||
// Parameters:
|
||||
// - w: The HTTP response writer
|
||||
// - r: The HTTP request
|
||||
func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {
|
||||
log.Debug("Serving success page")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Parse query parameters for customization
|
||||
query := r.URL.Query()
|
||||
setupRequired := query.Get("setup_required") == "true"
|
||||
platformURL := query.Get("platform_url")
|
||||
if platformURL == "" {
|
||||
platformURL = "https://platform.openai.com"
|
||||
}
|
||||
|
||||
// Generate success page HTML with dynamic content
|
||||
successHTML := s.generateSuccessHTML(setupRequired, platformURL)
|
||||
|
||||
_, err := w.Write([]byte(successHTML))
|
||||
if err != nil {
|
||||
log.Errorf("Failed to write success page: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// generateSuccessHTML creates the HTML content for the success page.
|
||||
// It customizes the page based on whether additional setup is required
|
||||
// and includes a link to the platform.
|
||||
//
|
||||
// Parameters:
|
||||
// - setupRequired: Whether additional setup is required after authentication
|
||||
// - platformURL: The URL to the platform for additional setup
|
||||
//
|
||||
// Returns:
|
||||
// - string: The HTML content for the success page
|
||||
func (s *OAuthServer) generateSuccessHTML(setupRequired bool, platformURL string) string {
|
||||
html := LoginSuccessHtml
|
||||
|
||||
// Replace platform URL placeholder
|
||||
html = strings.Replace(html, "{{PLATFORM_URL}}", platformURL, -1)
|
||||
|
||||
// Add setup notice if required
|
||||
if setupRequired {
|
||||
setupNotice := strings.Replace(SetupNoticeHtml, "{{PLATFORM_URL}}", platformURL, -1)
|
||||
html = strings.Replace(html, "{{SETUP_NOTICE}}", setupNotice, 1)
|
||||
} else {
|
||||
html = strings.Replace(html, "{{SETUP_NOTICE}}", "", 1)
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// sendResult sends the OAuth result to the waiting channel.
|
||||
// It ensures that the result is sent without blocking the handler.
|
||||
//
|
||||
// Parameters:
|
||||
// - result: The OAuth result to send
|
||||
func (s *OAuthServer) sendResult(result *OAuthResult) {
|
||||
select {
|
||||
case s.resultChan <- result:
|
||||
log.Debug("OAuth result sent to channel")
|
||||
default:
|
||||
log.Warn("OAuth result channel is full, result dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// isPortAvailable checks if the specified port is available.
|
||||
// It attempts to listen on the port to determine availability.
|
||||
//
|
||||
// Returns:
|
||||
// - bool: True if the port is available, false otherwise
|
||||
func (s *OAuthServer) isPortAvailable() bool {
|
||||
addr := fmt.Sprintf(":%d", s.port)
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
_ = listener.Close()
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
// IsRunning returns whether the server is currently running.
|
||||
//
|
||||
// Returns:
|
||||
// - bool: True if the server is running, false otherwise
|
||||
func (s *OAuthServer) IsRunning() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.running
|
||||
}
|
||||
39
backend/internal/auth/codex/openai.go
Normal file
39
backend/internal/auth/codex/openai.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package codex
|
||||
|
||||
// PKCECodes holds the verification codes for the OAuth2 PKCE (Proof Key for Code Exchange) flow.
|
||||
// PKCE is an extension to the Authorization Code flow to prevent CSRF and authorization code injection attacks.
|
||||
type PKCECodes struct {
|
||||
// CodeVerifier is the cryptographically random string used to correlate
|
||||
// the authorization request to the token request
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
// CodeChallenge is the SHA256 hash of the code verifier, base64url-encoded
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
}
|
||||
|
||||
// CodexTokenData holds the OAuth token information obtained from OpenAI.
|
||||
// It includes the ID token, access token, refresh token, and associated user details.
|
||||
type CodexTokenData struct {
|
||||
// IDToken is the JWT ID token containing user claims
|
||||
IDToken string `json:"id_token"`
|
||||
// AccessToken is the OAuth2 access token for API access
|
||||
AccessToken string `json:"access_token"`
|
||||
// RefreshToken is used to obtain new access tokens
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
// AccountID is the OpenAI account identifier
|
||||
AccountID string `json:"account_id"`
|
||||
// Email is the OpenAI account email
|
||||
Email string `json:"email"`
|
||||
// Expire is the timestamp of the token expire
|
||||
Expire string `json:"expired"`
|
||||
}
|
||||
|
||||
// CodexAuthBundle aggregates all authentication-related data after the OAuth flow is complete.
|
||||
// This includes the API key, token data, and the timestamp of the last refresh.
|
||||
type CodexAuthBundle struct {
|
||||
// APIKey is the OpenAI API key obtained from token exchange
|
||||
APIKey string `json:"api_key"`
|
||||
// TokenData contains the OAuth tokens from the authentication flow
|
||||
TokenData CodexTokenData `json:"token_data"`
|
||||
// LastRefresh is the timestamp of the last token refresh
|
||||
LastRefresh string `json:"last_refresh"`
|
||||
}
|
||||
349
backend/internal/auth/codex/openai_auth.go
Normal file
349
backend/internal/auth/codex/openai_auth.go
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
// Package codex provides authentication and token management for OpenAI's Codex API.
|
||||
// It handles the OAuth2 flow, including generating authorization URLs, exchanging
|
||||
// authorization codes for tokens, and refreshing expired tokens. The package also
|
||||
// defines data structures for storing and managing Codex authentication credentials.
|
||||
package codex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// OAuth configuration constants for OpenAI Codex
|
||||
const (
|
||||
AuthURL = "https://auth.openai.com/oauth/authorize"
|
||||
TokenURL = "https://auth.openai.com/oauth/token"
|
||||
ClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
RedirectURI = "http://localhost:1455/auth/callback"
|
||||
codexRefreshTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// CodexAuth handles the OpenAI OAuth2 authentication flow.
|
||||
// It manages the HTTP client and provides methods for generating authorization URLs,
|
||||
// exchanging authorization codes for tokens, and refreshing access tokens.
|
||||
type CodexAuth struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var codexRefreshGroup singleflight.Group
|
||||
|
||||
// NewCodexAuth creates a new CodexAuth service instance.
|
||||
// It initializes an HTTP client with proxy settings from the provided configuration.
|
||||
func NewCodexAuth(cfg *config.Config) *CodexAuth {
|
||||
return NewCodexAuthWithProxyURL(cfg, "")
|
||||
}
|
||||
|
||||
// NewCodexAuthWithProxyURL creates a new CodexAuth service instance.
|
||||
// proxyURL takes precedence over cfg.ProxyURL when non-empty.
|
||||
func NewCodexAuthWithProxyURL(cfg *config.Config, proxyURL string) *CodexAuth {
|
||||
effectiveProxyURL := strings.TrimSpace(proxyURL)
|
||||
var sdkCfg config.SDKConfig
|
||||
if cfg != nil {
|
||||
sdkCfg = cfg.SDKConfig
|
||||
if effectiveProxyURL == "" {
|
||||
effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL)
|
||||
}
|
||||
}
|
||||
sdkCfg.ProxyURL = effectiveProxyURL
|
||||
return &CodexAuth{
|
||||
httpClient: util.SetProxy(&sdkCfg, &http.Client{}),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAuthURL creates the OAuth authorization URL with PKCE (Proof Key for Code Exchange).
|
||||
// It constructs the URL with the necessary parameters, including the client ID,
|
||||
// response type, redirect URI, scopes, and PKCE challenge.
|
||||
func (o *CodexAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, error) {
|
||||
if pkceCodes == nil {
|
||||
return "", fmt.Errorf("PKCE codes are required")
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"client_id": {ClientID},
|
||||
"response_type": {"code"},
|
||||
"redirect_uri": {RedirectURI},
|
||||
"scope": {"openid email profile offline_access"},
|
||||
"state": {state},
|
||||
"code_challenge": {pkceCodes.CodeChallenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
"prompt": {"login"},
|
||||
"id_token_add_organizations": {"true"},
|
||||
"codex_cli_simplified_flow": {"true"},
|
||||
}
|
||||
|
||||
authURL := fmt.Sprintf("%s?%s", AuthURL, params.Encode())
|
||||
return authURL, nil
|
||||
}
|
||||
|
||||
// ExchangeCodeForTokens exchanges an authorization code for access and refresh tokens.
|
||||
// It performs an HTTP POST request to the OpenAI token endpoint with the provided
|
||||
// authorization code and PKCE verifier.
|
||||
func (o *CodexAuth) ExchangeCodeForTokens(ctx context.Context, code string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) {
|
||||
return o.ExchangeCodeForTokensWithRedirect(ctx, code, RedirectURI, pkceCodes)
|
||||
}
|
||||
|
||||
// ExchangeCodeForTokensWithRedirect exchanges an authorization code for tokens using
|
||||
// a caller-provided redirect URI. This supports alternate auth flows such as device
|
||||
// login while preserving the existing token parsing and storage behavior.
|
||||
func (o *CodexAuth) ExchangeCodeForTokensWithRedirect(ctx context.Context, code, redirectURI string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) {
|
||||
if pkceCodes == nil {
|
||||
return nil, fmt.Errorf("PKCE codes are required for token exchange")
|
||||
}
|
||||
if strings.TrimSpace(redirectURI) == "" {
|
||||
return nil, fmt.Errorf("redirect URI is required for token exchange")
|
||||
}
|
||||
|
||||
// Prepare token exchange request
|
||||
data := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"client_id": {ClientID},
|
||||
"code": {code},
|
||||
"redirect_uri": {strings.TrimSpace(redirectURI)},
|
||||
"code_verifier": {pkceCodes.CodeVerifier},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", TokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := o.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token exchange request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read token response: %w", err)
|
||||
}
|
||||
// log.Debugf("Token response: %s", string(body))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse token response
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
// Extract account ID from ID token
|
||||
claims, err := ParseJWTToken(tokenResp.IDToken)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to parse ID token: %v", err)
|
||||
}
|
||||
|
||||
accountID := ""
|
||||
email := ""
|
||||
if claims != nil {
|
||||
accountID = claims.GetAccountID()
|
||||
email = claims.GetUserEmail()
|
||||
}
|
||||
|
||||
// Create token data
|
||||
tokenData := CodexTokenData{
|
||||
IDToken: tokenResp.IDToken,
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
AccountID: accountID,
|
||||
Email: email,
|
||||
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Create auth bundle
|
||||
bundle := &CodexAuthBundle{
|
||||
TokenData: tokenData,
|
||||
LastRefresh: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
// RefreshTokens refreshes an access token using a refresh token.
|
||||
// This method is called when an access token has expired. It makes a request to the
|
||||
// token endpoint to obtain a new set of tokens.
|
||||
func (o *CodexAuth) RefreshTokens(ctx context.Context, refreshToken string) (*CodexTokenData, error) {
|
||||
if refreshToken == "" {
|
||||
return nil, fmt.Errorf("refresh token is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
result, err, _ := codexRefreshGroup.Do(refreshToken, func() (interface{}, error) {
|
||||
refreshCtx, cancelRefresh := context.WithTimeout(context.WithoutCancel(ctx), codexRefreshTimeout)
|
||||
defer cancelRefresh()
|
||||
return o.refreshTokensSingleFlight(refreshCtx, refreshToken)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenData, ok := result.(*CodexTokenData)
|
||||
if !ok || tokenData == nil {
|
||||
return nil, fmt.Errorf("token refresh failed: invalid single-flight result")
|
||||
}
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
func (o *CodexAuth) refreshTokensSingleFlight(ctx context.Context, refreshToken string) (*CodexTokenData, error) {
|
||||
data := url.Values{
|
||||
"client_id": {ClientID},
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {refreshToken},
|
||||
"scope": {"openid profile email"},
|
||||
}
|
||||
|
||||
req, errReq := http.NewRequestWithContext(ctx, "POST", TokenURL, strings.NewReader(data.Encode()))
|
||||
if errReq != nil {
|
||||
return nil, fmt.Errorf("failed to create refresh request: %w", errReq)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, errDo := o.httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return nil, fmt.Errorf("token refresh request failed: %w", errDo)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("token refresh response body close error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
body, errRead := io.ReadAll(resp.Body)
|
||||
if errRead != nil {
|
||||
return nil, fmt.Errorf("failed to read refresh response: %w", errRead)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
if errUnmarshal := json.Unmarshal(body, &tokenResp); errUnmarshal != nil {
|
||||
return nil, fmt.Errorf("failed to parse refresh response: %w", errUnmarshal)
|
||||
}
|
||||
|
||||
// Extract account ID from ID token
|
||||
claims, errParseJWT := ParseJWTToken(tokenResp.IDToken)
|
||||
if errParseJWT != nil {
|
||||
log.Warnf("Failed to parse refreshed ID token: %v", errParseJWT)
|
||||
}
|
||||
|
||||
accountID := ""
|
||||
email := ""
|
||||
if claims != nil {
|
||||
accountID = claims.GetAccountID()
|
||||
email = claims.Email
|
||||
}
|
||||
|
||||
return &CodexTokenData{
|
||||
IDToken: tokenResp.IDToken,
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
AccountID: accountID,
|
||||
Email: email,
|
||||
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateTokenStorage creates a new CodexTokenStorage from a CodexAuthBundle.
|
||||
// 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,
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
// RefreshTokensWithRetry refreshes tokens with a built-in retry mechanism.
|
||||
// It attempts to refresh the tokens up to a specified maximum number of retries,
|
||||
// with an exponential backoff strategy to handle transient network errors.
|
||||
func (o *CodexAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*CodexTokenData, error) {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// Wait before retry
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
tokenData, err := o.RefreshTokens(ctx, refreshToken)
|
||||
if err == nil {
|
||||
return tokenData, nil
|
||||
}
|
||||
if isNonRetryableRefreshErr(err) {
|
||||
log.Warnf("Token refresh attempt %d failed with non-retryable error: %v", attempt+1, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func isNonRetryableRefreshErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
raw := strings.ToLower(err.Error())
|
||||
return strings.Contains(raw, "refresh_token_reused")
|
||||
}
|
||||
|
||||
// UpdateTokenStorage updates an existing CodexTokenStorage with new token data.
|
||||
// This is typically called after a successful token refresh to persist the new credentials.
|
||||
func (o *CodexAuth) UpdateTokenStorage(storage *CodexTokenStorage, tokenData *CodexTokenData) {
|
||||
storage.IDToken = tokenData.IDToken
|
||||
storage.AccessToken = tokenData.AccessToken
|
||||
storage.RefreshToken = tokenData.RefreshToken
|
||||
storage.AccountID = tokenData.AccountID
|
||||
storage.LastRefresh = time.Now().Format(time.RFC3339)
|
||||
storage.Email = tokenData.Email
|
||||
storage.Expire = tokenData.Expire
|
||||
}
|
||||
195
backend/internal/auth/codex/openai_auth_test.go
Normal file
195
backend/internal/auth/codex/openai_auth_test.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
package codex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestNewCodexAuthDoesNotSetRequestTimeout(t *testing.T) {
|
||||
if got := NewCodexAuth(nil).httpClient.Timeout; got != 0 {
|
||||
t.Fatalf("HTTP client timeout = %s, want zero", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokens_UsesIndependentTimeout(t *testing.T) {
|
||||
resetCodexRefreshGroupForTest()
|
||||
defer resetCodexRefreshGroupForTest()
|
||||
|
||||
callerCtx, cancelCaller := context.WithCancel(context.Background())
|
||||
cancelCaller()
|
||||
var requestDeadline time.Time
|
||||
auth := &CodexAuth{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
var ok bool
|
||||
requestDeadline, ok = req.Context().Deadline()
|
||||
if !ok {
|
||||
t.Fatal("refresh request has no deadline")
|
||||
}
|
||||
if errContext := req.Context().Err(); errContext != nil {
|
||||
t.Fatalf("refresh request context is already done: %v", errContext)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"probe"}`)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := auth.RefreshTokens(callerCtx, "independent-timeout-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected refresh error")
|
||||
}
|
||||
if requestDeadline.IsZero() || !requestDeadline.After(time.Now()) {
|
||||
t.Fatalf("refresh deadline = %v, want a future deadline", requestDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
func resetCodexRefreshGroupForTest() {
|
||||
codexRefreshGroup = singleflight.Group{}
|
||||
}
|
||||
|
||||
func TestRefreshTokensWithRetry_NonRetryableOnlyAttemptsOnce(t *testing.T) {
|
||||
var calls int32
|
||||
auth := &CodexAuth{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"invalid_grant","code":"refresh_token_reused"}`)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := auth.RefreshTokensWithRetry(context.Background(), "dummy_refresh_token", 3)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for non-retryable refresh failure")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "refresh_token_reused") {
|
||||
t.Fatalf("expected refresh_token_reused in error, got: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected 1 refresh attempt, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokens_DeduplicatesConcurrentRefreshAcrossInstances(t *testing.T) {
|
||||
resetCodexRefreshGroupForTest()
|
||||
t.Cleanup(resetCodexRefreshGroupForTest)
|
||||
|
||||
var calls int32
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
once.Do(func() { close(started) })
|
||||
<-release
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"access_token":"new-access",
|
||||
"refresh_token":"new-refresh",
|
||||
"token_type":"Bearer",
|
||||
"expires_in":3600
|
||||
}`)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
authA := &CodexAuth{httpClient: &http.Client{Transport: transport}}
|
||||
authB := &CodexAuth{httpClient: &http.Client{Transport: transport}}
|
||||
|
||||
results := make(chan *CodexTokenData, 2)
|
||||
errs := make(chan error, 2)
|
||||
runRefresh := func(auth *CodexAuth, launched chan<- struct{}) {
|
||||
if launched != nil {
|
||||
close(launched)
|
||||
}
|
||||
tokenData, errRefresh := auth.RefreshTokens(context.Background(), "shared-refresh-token")
|
||||
results <- tokenData
|
||||
errs <- errRefresh
|
||||
}
|
||||
|
||||
go runRefresh(authA, nil)
|
||||
<-started
|
||||
|
||||
secondLaunched := make(chan struct{})
|
||||
go runRefresh(authB, secondLaunched)
|
||||
<-secondLaunched
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected concurrent refresh to share a single upstream call, got %d", got)
|
||||
}
|
||||
close(release)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if errRefresh := <-errs; errRefresh != nil {
|
||||
t.Fatalf("expected refresh to succeed, got %v", errRefresh)
|
||||
}
|
||||
tokenData := <-results
|
||||
if tokenData == nil || tokenData.AccessToken != "new-access" || tokenData.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("unexpected token data: %#v", tokenData)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected both refresh callers to share a single upstream call, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCodexAuthWithProxyURL_OverrideDirectDisablesProxy(t *testing.T) {
|
||||
cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://proxy.example.com:8080"}}
|
||||
auth := NewCodexAuthWithProxyURL(cfg, "direct")
|
||||
|
||||
transport, ok := auth.httpClient.Transport.(*http.Transport)
|
||||
if !ok || transport == nil {
|
||||
t.Fatalf("expected http.Transport, got %T", auth.httpClient.Transport)
|
||||
}
|
||||
if transport.Proxy != nil {
|
||||
t.Fatal("expected direct transport to disable proxy function")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCodexAuthWithProxyURL_OverrideProxyTakesPrecedence(t *testing.T) {
|
||||
cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://global.example.com:8080"}}
|
||||
auth := NewCodexAuthWithProxyURL(cfg, "http://override.example.com:8081")
|
||||
|
||||
transport, ok := auth.httpClient.Transport.(*http.Transport)
|
||||
if !ok || transport == nil {
|
||||
t.Fatalf("expected http.Transport, got %T", auth.httpClient.Transport)
|
||||
}
|
||||
req, errReq := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if errReq != nil {
|
||||
t.Fatalf("new request: %v", errReq)
|
||||
}
|
||||
proxyURL, errProxy := transport.Proxy(req)
|
||||
if errProxy != nil {
|
||||
t.Fatalf("proxy func: %v", errProxy)
|
||||
}
|
||||
if proxyURL == nil || proxyURL.String() != "http://override.example.com:8081" {
|
||||
t.Fatalf("proxy URL = %v, want http://override.example.com:8081", proxyURL)
|
||||
}
|
||||
}
|
||||
56
backend/internal/auth/codex/pkce.go
Normal file
56
backend/internal/auth/codex/pkce.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Package codex provides authentication and token management functionality
|
||||
// for OpenAI's Codex AI services. It handles OAuth2 PKCE (Proof Key for Code Exchange)
|
||||
// code generation for secure authentication flows.
|
||||
package codex
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GeneratePKCECodes generates a new pair of PKCE (Proof Key for Code Exchange) codes.
|
||||
// It creates a cryptographically random code verifier and its corresponding
|
||||
// SHA256 code challenge, as specified in RFC 7636. This is a critical security
|
||||
// feature for the OAuth 2.0 authorization code flow.
|
||||
func GeneratePKCECodes() (*PKCECodes, error) {
|
||||
// Generate code verifier: 43-128 characters, URL-safe
|
||||
codeVerifier, err := generateCodeVerifier()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate code verifier: %w", err)
|
||||
}
|
||||
|
||||
// Generate code challenge using S256 method
|
||||
codeChallenge := generateCodeChallenge(codeVerifier)
|
||||
|
||||
return &PKCECodes{
|
||||
CodeVerifier: codeVerifier,
|
||||
CodeChallenge: codeChallenge,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateCodeVerifier creates a cryptographically secure random string to be used
|
||||
// as the code verifier in the PKCE flow. The verifier is a high-entropy string
|
||||
// that is later used to prove possession of the client that initiated the
|
||||
// authorization request.
|
||||
func generateCodeVerifier() (string, error) {
|
||||
// Generate 96 random bytes (will result in 128 base64 characters)
|
||||
bytes := make([]byte, 96)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
|
||||
// Encode to URL-safe base64 without padding
|
||||
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// generateCodeChallenge creates a code challenge from a given code verifier.
|
||||
// The challenge is derived by taking the SHA256 hash of the verifier and then
|
||||
// Base64 URL-encoding the result. This is sent in the initial authorization
|
||||
// request and later verified against the verifier.
|
||||
func generateCodeChallenge(codeVerifier string) string {
|
||||
hash := sha256.Sum256([]byte(codeVerifier))
|
||||
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
|
||||
}
|
||||
84
backend/internal/auth/codex/token.go
Normal file
84
backend/internal/auth/codex/token.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// Package codex provides authentication and token management functionality
|
||||
// for OpenAI's Codex AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Codex API.
|
||||
package codex
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// CodexTokenStorage stores OAuth2 token information for OpenAI Codex API authentication.
|
||||
// It maintains compatibility with the existing auth system while adding Codex-specific fields
|
||||
// for managing access tokens, refresh tokens, and user account information.
|
||||
type CodexTokenStorage struct {
|
||||
// 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"`
|
||||
// AccountID is the OpenAI account identifier associated with this token.
|
||||
AccountID string `json:"account_id"`
|
||||
// LastRefresh is the timestamp of the last token refresh operation.
|
||||
LastRefresh string `json:"last_refresh"`
|
||||
// Email is the OpenAI account email address associated with this token.
|
||||
Email string `json:"email"`
|
||||
// Type indicates the authentication provider type, always "codex" for this storage.
|
||||
Type string `json:"type"`
|
||||
// Expire is the timestamp when the current access token expires.
|
||||
Expire string `json:"expired"`
|
||||
|
||||
// Metadata holds arbitrary key-value pairs injected via hooks.
|
||||
// It is not exported to JSON directly to allow flattening during serialization.
|
||||
Metadata map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
// SetMetadata allows external callers to inject metadata into the storage before saving.
|
||||
func (ts *CodexTokenStorage) SetMetadata(meta map[string]any) {
|
||||
ts.Metadata = meta
|
||||
}
|
||||
|
||||
// SaveTokenToFile serializes the Codex token storage to a JSON file.
|
||||
// This method creates the necessary directory structure and writes the token
|
||||
// data in JSON format to the specified file path for persistent storage.
|
||||
// It merges any injected metadata into the top-level JSON object.
|
||||
//
|
||||
// Parameters:
|
||||
// - authFilePath: The full path where the token file should be saved
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the operation fails, nil otherwise
|
||||
func (ts *CodexTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
misc.LogSavingCredentials(authFilePath)
|
||||
ts.Type = "codex"
|
||||
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
|
||||
// Merge metadata using helper
|
||||
data, errMerge := misc.MergeMetadata(ts, ts.Metadata)
|
||||
if errMerge != nil {
|
||||
return fmt.Errorf("failed to merge metadata: %w", errMerge)
|
||||
}
|
||||
|
||||
f, err := os.Create(authFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create token file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := f.Close(); errClose != nil {
|
||||
log.Errorf("codex token storage: close token file error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
if err = json.NewEncoder(f).Encode(data); err != nil {
|
||||
return fmt.Errorf("failed to write token to file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
77
backend/internal/auth/codex/token_test.go
Normal file
77
backend/internal/auth/codex/token_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package codex
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveTokenToFile_PreservesCustomMetadata(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
authFilePath := filepath.Join(tempDir, "codex-test.json")
|
||||
|
||||
storage := &CodexTokenStorage{
|
||||
Type: "codex",
|
||||
Email: "user@example.com",
|
||||
AccessToken: "new-access-token",
|
||||
RefreshToken: "new-refresh-token",
|
||||
IDToken: "new-id-token",
|
||||
AccountID: "new-account",
|
||||
Expire: "2026-12-31T23:59:59Z",
|
||||
LastRefresh: "2026-04-14T12:00:00Z",
|
||||
}
|
||||
storage.SetMetadata(map[string]any{
|
||||
"disabled": false,
|
||||
"prefix": "my-prefix",
|
||||
"websockets": false,
|
||||
"note": "my important note",
|
||||
"proxy_url": "http://proxy:8080",
|
||||
"weight": float64(42),
|
||||
})
|
||||
|
||||
if errSave := storage.SaveTokenToFile(authFilePath); errSave != nil {
|
||||
t.Fatalf("SaveTokenToFile() error = %v", errSave)
|
||||
}
|
||||
|
||||
savedRaw, errRead := os.ReadFile(authFilePath)
|
||||
if errRead != nil {
|
||||
t.Fatalf("os.ReadFile error = %v", errRead)
|
||||
}
|
||||
|
||||
var saved map[string]any
|
||||
if errUnmarshal := json.Unmarshal(savedRaw, &saved); errUnmarshal != nil {
|
||||
t.Fatalf("json.Unmarshal error = %v", errUnmarshal)
|
||||
}
|
||||
|
||||
// Verify updated OAuth token fields
|
||||
if saved["access_token"] != "new-access-token" {
|
||||
t.Errorf("access_token = %v, want new-access-token", saved["access_token"])
|
||||
}
|
||||
if saved["refresh_token"] != "new-refresh-token" {
|
||||
t.Errorf("refresh_token = %v, want new-refresh-token", saved["refresh_token"])
|
||||
}
|
||||
if saved["id_token"] != "new-id-token" {
|
||||
t.Errorf("id_token = %v, want new-id-token", saved["id_token"])
|
||||
}
|
||||
if saved["account_id"] != "new-account" {
|
||||
t.Errorf("account_id = %v, want new-account", saved["account_id"])
|
||||
}
|
||||
|
||||
// Verify custom fields in metadata
|
||||
if saved["prefix"] != "my-prefix" {
|
||||
t.Errorf("prefix = %v, want my-prefix", saved["prefix"])
|
||||
}
|
||||
if saved["websockets"] != false {
|
||||
t.Errorf("websockets = %v, want false", saved["websockets"])
|
||||
}
|
||||
if saved["note"] != "my important note" {
|
||||
t.Errorf("note = %v, want my important note", saved["note"])
|
||||
}
|
||||
if saved["proxy_url"] != "http://proxy:8080" {
|
||||
t.Errorf("proxy_url = %v, want http://proxy:8080", saved["proxy_url"])
|
||||
}
|
||||
if saved["weight"] != float64(42) {
|
||||
t.Errorf("weight = %v, want 42", saved["weight"])
|
||||
}
|
||||
}
|
||||
26
backend/internal/auth/empty/token.go
Normal file
26
backend/internal/auth/empty/token.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Package empty provides a no-operation token storage implementation.
|
||||
// This package is used when authentication tokens are not required or when
|
||||
// using API key-based authentication instead of OAuth tokens for any provider.
|
||||
package empty
|
||||
|
||||
// EmptyStorage is a no-operation implementation of the TokenStorage interface.
|
||||
// It provides empty implementations for scenarios where token storage is not needed,
|
||||
// such as when using API keys instead of OAuth tokens for authentication.
|
||||
type EmptyStorage struct {
|
||||
// Type indicates the authentication provider type, always "empty" for this implementation.
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// SaveTokenToFile is a no-operation implementation that always succeeds.
|
||||
// This method satisfies the TokenStorage interface but performs no actual file operations
|
||||
// since empty storage doesn't require persistent token data.
|
||||
//
|
||||
// Parameters:
|
||||
// - _: The file path parameter is ignored in this implementation
|
||||
//
|
||||
// Returns:
|
||||
// - error: Always returns nil (no error)
|
||||
func (ts *EmptyStorage) SaveTokenToFile(_ string) error {
|
||||
ts.Type = "empty"
|
||||
return nil
|
||||
}
|
||||
436
backend/internal/auth/kimi/kimi.go
Normal file
436
backend/internal/auth/kimi/kimi.go
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
// Package kimi provides authentication and token management for Kimi (Moonshot AI) API.
|
||||
// It handles the RFC 8628 OAuth2 Device Authorization Grant flow for secure authentication.
|
||||
package kimi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const (
|
||||
// kimiClientID is Kimi Code's OAuth client ID.
|
||||
kimiClientID = "17e5f671-d194-4dfb-9706-5516cb48c098"
|
||||
// kimiOAuthHost is the OAuth server endpoint.
|
||||
kimiOAuthHost = "https://auth.kimi.com"
|
||||
// kimiDeviceCodeURL is the endpoint for requesting device codes.
|
||||
kimiDeviceCodeURL = kimiOAuthHost + "/api/oauth/device_authorization"
|
||||
// kimiTokenURL is the endpoint for exchanging device codes for tokens.
|
||||
kimiTokenURL = kimiOAuthHost + "/api/oauth/token"
|
||||
// KimiAPIBaseURL is the base URL for Kimi API requests.
|
||||
KimiAPIBaseURL = "https://api.kimi.com/coding"
|
||||
// defaultPollInterval is the default interval for polling token endpoint.
|
||||
defaultPollInterval = 5 * time.Second
|
||||
// maxPollDuration is the maximum time to wait for user authorization.
|
||||
maxPollDuration = 15 * time.Minute
|
||||
// refreshThresholdSeconds is when to refresh token before expiry (5 minutes).
|
||||
refreshThresholdSeconds = 300
|
||||
)
|
||||
|
||||
var kimiRefreshGroup singleflight.Group
|
||||
|
||||
// KimiAuth handles Kimi authentication flow.
|
||||
type KimiAuth struct {
|
||||
deviceClient *DeviceFlowClient
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewKimiAuth creates a new KimiAuth service instance.
|
||||
func NewKimiAuth(cfg *config.Config) *KimiAuth {
|
||||
return &KimiAuth{
|
||||
deviceClient: NewDeviceFlowClient(cfg),
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// StartDeviceFlow initiates the device flow authentication.
|
||||
func (k *KimiAuth) StartDeviceFlow(ctx context.Context) (*DeviceCodeResponse, error) {
|
||||
return k.deviceClient.RequestDeviceCode(ctx)
|
||||
}
|
||||
|
||||
// WaitForAuthorization polls for user authorization and returns the auth bundle.
|
||||
func (k *KimiAuth) WaitForAuthorization(ctx context.Context, deviceCode *DeviceCodeResponse) (*KimiAuthBundle, error) {
|
||||
tokenData, err := k.deviceClient.PollForToken(ctx, deviceCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &KimiAuthBundle{
|
||||
TokenData: tokenData,
|
||||
DeviceID: k.deviceClient.deviceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateTokenStorage creates a new KimiTokenStorage from auth bundle.
|
||||
func (k *KimiAuth) CreateTokenStorage(bundle *KimiAuthBundle) *KimiTokenStorage {
|
||||
expired := ""
|
||||
if bundle.TokenData.ExpiresAt > 0 {
|
||||
expired = time.Unix(bundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
return &KimiTokenStorage{
|
||||
AccessToken: bundle.TokenData.AccessToken,
|
||||
RefreshToken: bundle.TokenData.RefreshToken,
|
||||
TokenType: bundle.TokenData.TokenType,
|
||||
Scope: bundle.TokenData.Scope,
|
||||
DeviceID: strings.TrimSpace(bundle.DeviceID),
|
||||
Expired: expired,
|
||||
Type: "kimi",
|
||||
}
|
||||
}
|
||||
|
||||
// DeviceFlowClient handles the OAuth2 device flow for Kimi.
|
||||
type DeviceFlowClient struct {
|
||||
httpClient *http.Client
|
||||
cfg *config.Config
|
||||
deviceID string
|
||||
}
|
||||
|
||||
// NewDeviceFlowClient creates a new device flow client.
|
||||
func NewDeviceFlowClient(cfg *config.Config) *DeviceFlowClient {
|
||||
return NewDeviceFlowClientWithDeviceID(cfg, "")
|
||||
}
|
||||
|
||||
// NewDeviceFlowClientWithDeviceID creates a new device flow client with the specified device ID.
|
||||
func NewDeviceFlowClientWithDeviceID(cfg *config.Config, deviceID string) *DeviceFlowClient {
|
||||
return NewDeviceFlowClientWithDeviceIDAndProxyURL(cfg, deviceID, "")
|
||||
}
|
||||
|
||||
// NewDeviceFlowClientWithDeviceIDAndProxyURL creates a new device flow client with a proxy override.
|
||||
// proxyURL takes precedence over cfg.ProxyURL when non-empty.
|
||||
func NewDeviceFlowClientWithDeviceIDAndProxyURL(cfg *config.Config, deviceID string, proxyURL string) *DeviceFlowClient {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
effectiveProxyURL := strings.TrimSpace(proxyURL)
|
||||
var sdkCfg config.SDKConfig
|
||||
if cfg != nil {
|
||||
sdkCfg = cfg.SDKConfig
|
||||
if effectiveProxyURL == "" {
|
||||
effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL)
|
||||
}
|
||||
}
|
||||
sdkCfg.ProxyURL = effectiveProxyURL
|
||||
client = util.SetProxy(&sdkCfg, client)
|
||||
|
||||
resolvedDeviceID := strings.TrimSpace(deviceID)
|
||||
if resolvedDeviceID == "" {
|
||||
resolvedDeviceID = getOrCreateDeviceID()
|
||||
}
|
||||
return &DeviceFlowClient{
|
||||
httpClient: client,
|
||||
cfg: cfg,
|
||||
deviceID: resolvedDeviceID,
|
||||
}
|
||||
}
|
||||
|
||||
// getOrCreateDeviceID returns an in-memory device ID for the current authentication flow.
|
||||
func getOrCreateDeviceID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// getDeviceModel returns a device model string.
|
||||
func getDeviceModel() string {
|
||||
osName := runtime.GOOS
|
||||
arch := runtime.GOARCH
|
||||
|
||||
switch osName {
|
||||
case "darwin":
|
||||
return fmt.Sprintf("macOS %s", arch)
|
||||
case "windows":
|
||||
return fmt.Sprintf("Windows %s", arch)
|
||||
case "linux":
|
||||
return fmt.Sprintf("Linux %s", arch)
|
||||
default:
|
||||
return fmt.Sprintf("%s %s", osName, arch)
|
||||
}
|
||||
}
|
||||
|
||||
// getHostname returns the machine hostname.
|
||||
func getHostname() string {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return hostname
|
||||
}
|
||||
|
||||
// commonHeaders returns headers required for Kimi API requests.
|
||||
func (c *DeviceFlowClient) commonHeaders() map[string]string {
|
||||
return map[string]string{
|
||||
"X-Msh-Platform": "CLIProxyAPI",
|
||||
"X-Msh-Version": buildinfo.Version,
|
||||
"X-Msh-Device-Name": getHostname(),
|
||||
"X-Msh-Device-Model": getDeviceModel(),
|
||||
"X-Msh-Device-Id": c.deviceID,
|
||||
}
|
||||
}
|
||||
|
||||
// RequestDeviceCode initiates the device flow by requesting a device code from Kimi.
|
||||
func (c *DeviceFlowClient) RequestDeviceCode(ctx context.Context) (*DeviceCodeResponse, error) {
|
||||
data := url.Values{}
|
||||
data.Set("client_id", kimiClientID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, kimiDeviceCodeURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kimi: failed to create device code request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
for k, v := range c.commonHeaders() {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kimi: device code request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("kimi device code: close body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kimi: failed to read device code response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("kimi: device code request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var deviceCode DeviceCodeResponse
|
||||
if err = json.Unmarshal(bodyBytes, &deviceCode); err != nil {
|
||||
return nil, fmt.Errorf("kimi: failed to parse device code response: %w", err)
|
||||
}
|
||||
|
||||
return &deviceCode, nil
|
||||
}
|
||||
|
||||
// PollForToken polls the token endpoint until the user authorizes or the device code expires.
|
||||
func (c *DeviceFlowClient) PollForToken(ctx context.Context, deviceCode *DeviceCodeResponse) (*KimiTokenData, error) {
|
||||
if deviceCode == nil {
|
||||
return nil, fmt.Errorf("kimi: device code is nil")
|
||||
}
|
||||
|
||||
interval := time.Duration(deviceCode.Interval) * time.Second
|
||||
if interval < defaultPollInterval {
|
||||
interval = defaultPollInterval
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(maxPollDuration)
|
||||
if deviceCode.ExpiresIn > 0 {
|
||||
codeDeadline := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second)
|
||||
if codeDeadline.Before(deadline) {
|
||||
deadline = codeDeadline
|
||||
}
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("kimi: context cancelled: %w", ctx.Err())
|
||||
case <-ticker.C:
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("kimi: device code expired")
|
||||
}
|
||||
|
||||
token, pollErr, shouldContinue := c.exchangeDeviceCode(ctx, deviceCode.DeviceCode)
|
||||
if token != nil {
|
||||
return token, nil
|
||||
}
|
||||
if !shouldContinue {
|
||||
return nil, pollErr
|
||||
}
|
||||
// Continue polling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// exchangeDeviceCode attempts to exchange the device code for an access token.
|
||||
// Returns (token, error, shouldContinue).
|
||||
func (c *DeviceFlowClient) exchangeDeviceCode(ctx context.Context, deviceCode string) (*KimiTokenData, error, bool) {
|
||||
data := url.Values{}
|
||||
data.Set("client_id", kimiClientID)
|
||||
data.Set("device_code", deviceCode)
|
||||
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, kimiTokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kimi: failed to create token request: %w", err), false
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
for k, v := range c.commonHeaders() {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kimi: token request failed: %w", err), false
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("kimi token exchange: close body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kimi: failed to read token response: %w", err), false
|
||||
}
|
||||
|
||||
// Parse response - Kimi returns 200 for both success and pending states
|
||||
var oauthResp struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn float64 `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(bodyBytes, &oauthResp); err != nil {
|
||||
return nil, fmt.Errorf("kimi: failed to parse token response: %w", err), false
|
||||
}
|
||||
|
||||
if oauthResp.Error != "" {
|
||||
switch oauthResp.Error {
|
||||
case "authorization_pending":
|
||||
return nil, nil, true // Continue polling
|
||||
case "slow_down":
|
||||
return nil, nil, true // Continue polling (with increased interval handled by caller)
|
||||
case "expired_token":
|
||||
return nil, fmt.Errorf("kimi: device code expired"), false
|
||||
case "access_denied":
|
||||
return nil, fmt.Errorf("kimi: access denied by user"), false
|
||||
default:
|
||||
return nil, fmt.Errorf("kimi: OAuth error: %s - %s", oauthResp.Error, oauthResp.ErrorDescription), false
|
||||
}
|
||||
}
|
||||
|
||||
if oauthResp.AccessToken == "" {
|
||||
return nil, fmt.Errorf("kimi: empty access token in response"), false
|
||||
}
|
||||
|
||||
var expiresAt int64
|
||||
if oauthResp.ExpiresIn > 0 {
|
||||
expiresAt = time.Now().Unix() + int64(oauthResp.ExpiresIn)
|
||||
}
|
||||
|
||||
return &KimiTokenData{
|
||||
AccessToken: oauthResp.AccessToken,
|
||||
RefreshToken: oauthResp.RefreshToken,
|
||||
TokenType: oauthResp.TokenType,
|
||||
ExpiresAt: expiresAt,
|
||||
Scope: oauthResp.Scope,
|
||||
}, nil, false
|
||||
}
|
||||
|
||||
// RefreshToken exchanges a refresh token for a new access token.
|
||||
func (c *DeviceFlowClient) RefreshToken(ctx context.Context, refreshToken string) (*KimiTokenData, error) {
|
||||
if strings.TrimSpace(refreshToken) == "" {
|
||||
return nil, fmt.Errorf("kimi: refresh token is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
refreshToken = strings.TrimSpace(refreshToken)
|
||||
|
||||
result, err, _ := kimiRefreshGroup.Do(refreshToken, func() (interface{}, error) {
|
||||
return c.refreshTokenSingleFlight(context.WithoutCancel(ctx), refreshToken)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenData, ok := result.(*KimiTokenData)
|
||||
if !ok || tokenData == nil {
|
||||
return nil, fmt.Errorf("kimi: refresh token failed: invalid single-flight result")
|
||||
}
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
func (c *DeviceFlowClient) refreshTokenSingleFlight(ctx context.Context, refreshToken string) (*KimiTokenData, error) {
|
||||
data := url.Values{}
|
||||
data.Set("client_id", kimiClientID)
|
||||
data.Set("grant_type", "refresh_token")
|
||||
data.Set("refresh_token", refreshToken)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, kimiTokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kimi: failed to create refresh request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
for k, v := range c.commonHeaders() {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kimi: refresh request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("kimi refresh token: close body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kimi: failed to read refresh response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return nil, fmt.Errorf("kimi: refresh token rejected (status %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("kimi: refresh failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn float64 `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(bodyBytes, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("kimi: failed to parse refresh response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" {
|
||||
return nil, fmt.Errorf("kimi: empty access token in refresh response")
|
||||
}
|
||||
|
||||
var expiresAt int64
|
||||
if tokenResp.ExpiresIn > 0 {
|
||||
expiresAt = time.Now().Unix() + int64(tokenResp.ExpiresIn)
|
||||
}
|
||||
|
||||
return &KimiTokenData{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
TokenType: tokenResp.TokenType,
|
||||
ExpiresAt: expiresAt,
|
||||
Scope: tokenResp.Scope,
|
||||
}, nil
|
||||
}
|
||||
42
backend/internal/auth/kimi/kimi_proxy_test.go
Normal file
42
backend/internal/auth/kimi/kimi_proxy_test.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package kimi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
func TestNewDeviceFlowClientWithDeviceIDAndProxyURL_OverrideDirectDisablesProxy(t *testing.T) {
|
||||
cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://proxy.example.com:8080"}}
|
||||
client := NewDeviceFlowClientWithDeviceIDAndProxyURL(cfg, "device-1", "direct")
|
||||
|
||||
transport, ok := client.httpClient.Transport.(*http.Transport)
|
||||
if !ok || transport == nil {
|
||||
t.Fatalf("expected http.Transport, got %T", client.httpClient.Transport)
|
||||
}
|
||||
if transport.Proxy != nil {
|
||||
t.Fatal("expected direct transport to disable proxy function")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeviceFlowClientWithDeviceIDAndProxyURL_OverrideProxyTakesPrecedence(t *testing.T) {
|
||||
cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://global.example.com:8080"}}
|
||||
client := NewDeviceFlowClientWithDeviceIDAndProxyURL(cfg, "device-1", "http://override.example.com:8081")
|
||||
|
||||
transport, ok := client.httpClient.Transport.(*http.Transport)
|
||||
if !ok || transport == nil {
|
||||
t.Fatalf("expected http.Transport, got %T", client.httpClient.Transport)
|
||||
}
|
||||
req, errReq := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if errReq != nil {
|
||||
t.Fatalf("new request: %v", errReq)
|
||||
}
|
||||
proxyURL, errProxy := transport.Proxy(req)
|
||||
if errProxy != nil {
|
||||
t.Fatalf("proxy func: %v", errProxy)
|
||||
}
|
||||
if proxyURL == nil || proxyURL.String() != "http://override.example.com:8081" {
|
||||
t.Fatalf("proxy URL = %v, want http://override.example.com:8081", proxyURL)
|
||||
}
|
||||
}
|
||||
89
backend/internal/auth/kimi/kimi_refresh_test.go
Normal file
89
backend/internal/auth/kimi/kimi_refresh_test.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package kimi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
type kimiRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f kimiRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func resetKimiRefreshGroupForTest() {
|
||||
kimiRefreshGroup = singleflight.Group{}
|
||||
}
|
||||
|
||||
func TestRefreshToken_DeduplicatesConcurrentRefreshAcrossInstances(t *testing.T) {
|
||||
resetKimiRefreshGroupForTest()
|
||||
t.Cleanup(resetKimiRefreshGroupForTest)
|
||||
|
||||
var calls int32
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
|
||||
transport := kimiRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
once.Do(func() { close(started) })
|
||||
<-release
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"access_token":"new-access",
|
||||
"refresh_token":"new-refresh",
|
||||
"token_type":"Bearer",
|
||||
"expires_in":3600
|
||||
}`)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
clientA := &DeviceFlowClient{httpClient: &http.Client{Transport: transport}}
|
||||
clientB := &DeviceFlowClient{httpClient: &http.Client{Transport: transport}}
|
||||
|
||||
results := make(chan *KimiTokenData, 2)
|
||||
errs := make(chan error, 2)
|
||||
runRefresh := func(client *DeviceFlowClient, launched chan<- struct{}) {
|
||||
if launched != nil {
|
||||
close(launched)
|
||||
}
|
||||
tokenData, errRefresh := client.RefreshToken(context.Background(), "shared-refresh-token")
|
||||
results <- tokenData
|
||||
errs <- errRefresh
|
||||
}
|
||||
|
||||
go runRefresh(clientA, nil)
|
||||
<-started
|
||||
|
||||
secondLaunched := make(chan struct{})
|
||||
go runRefresh(clientB, secondLaunched)
|
||||
<-secondLaunched
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected concurrent refresh to share a single upstream call, got %d", got)
|
||||
}
|
||||
close(release)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if errRefresh := <-errs; errRefresh != nil {
|
||||
t.Fatalf("expected refresh to succeed, got %v", errRefresh)
|
||||
}
|
||||
tokenData := <-results
|
||||
if tokenData == nil || tokenData.AccessToken != "new-access" || tokenData.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("unexpected token data: %#v", tokenData)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected both refresh callers to share a single upstream call, got %d", got)
|
||||
}
|
||||
}
|
||||
134
backend/internal/auth/kimi/token.go
Normal file
134
backend/internal/auth/kimi/token.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// Package kimi provides authentication and token management functionality
|
||||
// for Kimi (Moonshot AI) services. It handles OAuth2 device flow token storage,
|
||||
// serialization, and retrieval for maintaining authenticated sessions with the Kimi API.
|
||||
package kimi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// KimiTokenStorage stores OAuth2 token information for Kimi API authentication.
|
||||
type KimiTokenStorage struct {
|
||||
// AccessToken is the OAuth2 access token used for authenticating API requests.
|
||||
AccessToken string `json:"access_token"`
|
||||
// RefreshToken is the OAuth2 refresh token used to obtain new access tokens.
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
// TokenType is the type of token, typically "Bearer".
|
||||
TokenType string `json:"token_type"`
|
||||
// Scope is the OAuth2 scope granted to the token.
|
||||
Scope string `json:"scope,omitempty"`
|
||||
// DeviceID is the OAuth device flow identifier used for Kimi requests.
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
// Expired is the RFC3339 timestamp when the access token expires.
|
||||
Expired string `json:"expired,omitempty"`
|
||||
// Type indicates the authentication provider type, always "kimi" for this storage.
|
||||
Type string `json:"type"`
|
||||
|
||||
// Metadata holds arbitrary key-value pairs injected via hooks.
|
||||
// It is not exported to JSON directly to allow flattening during serialization.
|
||||
Metadata map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
// SetMetadata allows external callers to inject metadata into the storage before saving.
|
||||
func (ts *KimiTokenStorage) SetMetadata(meta map[string]any) {
|
||||
ts.Metadata = meta
|
||||
}
|
||||
|
||||
// KimiTokenData holds the raw OAuth token response from Kimi.
|
||||
type KimiTokenData struct {
|
||||
// AccessToken is the OAuth2 access token.
|
||||
AccessToken string `json:"access_token"`
|
||||
// RefreshToken is the OAuth2 refresh token.
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
// TokenType is the type of token, typically "Bearer".
|
||||
TokenType string `json:"token_type"`
|
||||
// ExpiresAt is the Unix timestamp when the token expires.
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
// Scope is the OAuth2 scope granted to the token.
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// KimiAuthBundle bundles authentication data for storage.
|
||||
type KimiAuthBundle struct {
|
||||
// TokenData contains the OAuth token information.
|
||||
TokenData *KimiTokenData
|
||||
// DeviceID is the device identifier used during OAuth device flow.
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
// DeviceCodeResponse represents Kimi's device code response.
|
||||
type DeviceCodeResponse struct {
|
||||
// DeviceCode is the device verification code.
|
||||
DeviceCode string `json:"device_code"`
|
||||
// UserCode is the code the user must enter at the verification URI.
|
||||
UserCode string `json:"user_code"`
|
||||
// VerificationURI is the URL where the user should enter the code.
|
||||
VerificationURI string `json:"verification_uri,omitempty"`
|
||||
// VerificationURIComplete is the URL with the code pre-filled.
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
// ExpiresIn is the number of seconds until the device code expires.
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
// Interval is the minimum number of seconds to wait between polling requests.
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
// SaveTokenToFile serializes the Kimi token storage to a JSON file.
|
||||
func (ts *KimiTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
misc.LogSavingCredentials(authFilePath)
|
||||
ts.Type = "kimi"
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
|
||||
// Merge metadata using helper
|
||||
data, errMerge := misc.MergeMetadata(ts, ts.Metadata)
|
||||
if errMerge != nil {
|
||||
return fmt.Errorf("failed to merge metadata: %w", errMerge)
|
||||
}
|
||||
|
||||
f, err := os.Create(authFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create token file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := f.Close(); errClose != nil {
|
||||
log.Errorf("kimi token storage: close token file error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
encoder := json.NewEncoder(f)
|
||||
encoder.SetIndent("", " ")
|
||||
if err = encoder.Encode(data); err != nil {
|
||||
return fmt.Errorf("failed to write token to file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsExpired checks if the token has expired.
|
||||
func (ts *KimiTokenStorage) IsExpired() bool {
|
||||
if ts.Expired == "" {
|
||||
return false // No expiry set, assume valid
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ts.Expired)
|
||||
if err != nil {
|
||||
return true // Has expiry string but can't parse
|
||||
}
|
||||
// Consider expired if within refresh threshold
|
||||
return time.Now().Add(time.Duration(refreshThresholdSeconds) * time.Second).After(t)
|
||||
}
|
||||
|
||||
// NeedsRefresh checks if the token should be refreshed.
|
||||
func (ts *KimiTokenStorage) NeedsRefresh() bool {
|
||||
if ts.RefreshToken == "" {
|
||||
return false // Can't refresh without refresh token
|
||||
}
|
||||
return ts.IsExpired()
|
||||
}
|
||||
17
backend/internal/auth/models.go
Normal file
17
backend/internal/auth/models.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Package auth provides authentication functionality for various AI service providers.
|
||||
// It includes interfaces and implementations for token storage and authentication methods.
|
||||
package auth
|
||||
|
||||
// TokenStorage defines the interface for storing authentication tokens.
|
||||
// Implementations of this interface should provide methods to persist
|
||||
// authentication tokens to a file system location.
|
||||
type TokenStorage interface {
|
||||
// SaveTokenToFile persists authentication tokens to the specified file path.
|
||||
//
|
||||
// Parameters:
|
||||
// - authFilePath: The file path where the authentication tokens should be saved
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the save operation fails, nil otherwise
|
||||
SaveTokenToFile(authFilePath string) error
|
||||
}
|
||||
208
backend/internal/auth/vertex/keyutil.go
Normal file
208
backend/internal/auth/vertex/keyutil.go
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
package vertex
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NormalizeServiceAccountJSON normalizes the given JSON-encoded service account payload.
|
||||
// It returns the normalized JSON (with sanitized private_key) or, if normalization fails,
|
||||
// the original bytes and the encountered error.
|
||||
func NormalizeServiceAccountJSON(raw []byte) ([]byte, error) {
|
||||
if len(raw) == 0 {
|
||||
return raw, nil
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return raw, err
|
||||
}
|
||||
normalized, err := NormalizeServiceAccountMap(payload)
|
||||
if err != nil {
|
||||
return raw, err
|
||||
}
|
||||
out, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return raw, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NormalizeServiceAccountMap returns a copy of the given service account map with
|
||||
// a sanitized private_key field that is guaranteed to contain a valid RSA PRIVATE KEY PEM block.
|
||||
func NormalizeServiceAccountMap(sa map[string]any) (map[string]any, error) {
|
||||
if sa == nil {
|
||||
return nil, fmt.Errorf("service account payload is empty")
|
||||
}
|
||||
pk, _ := sa["private_key"].(string)
|
||||
if strings.TrimSpace(pk) == "" {
|
||||
return nil, fmt.Errorf("service account missing private_key")
|
||||
}
|
||||
normalized, err := sanitizePrivateKey(pk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clone := make(map[string]any, len(sa))
|
||||
for k, v := range sa {
|
||||
clone[k] = v
|
||||
}
|
||||
clone["private_key"] = normalized
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
func sanitizePrivateKey(raw string) (string, error) {
|
||||
pk := strings.ReplaceAll(raw, "\r\n", "\n")
|
||||
pk = strings.ReplaceAll(pk, "\r", "\n")
|
||||
pk = stripANSIEscape(pk)
|
||||
pk = strings.ToValidUTF8(pk, "")
|
||||
pk = strings.TrimSpace(pk)
|
||||
|
||||
normalized := pk
|
||||
if block, _ := pem.Decode([]byte(pk)); block == nil {
|
||||
// Attempt to reconstruct from the textual payload.
|
||||
if reconstructed, err := rebuildPEM(pk); err == nil {
|
||||
normalized = reconstructed
|
||||
} else {
|
||||
return "", fmt.Errorf("private_key is not valid pem: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
block, _ := pem.Decode([]byte(normalized))
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("private_key pem decode failed")
|
||||
}
|
||||
|
||||
rsaBlock, err := ensureRSAPrivateKey(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(pem.EncodeToMemory(rsaBlock)), nil
|
||||
}
|
||||
|
||||
func ensureRSAPrivateKey(block *pem.Block) (*pem.Block, error) {
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("pem block is nil")
|
||||
}
|
||||
|
||||
if block.Type == "RSA PRIVATE KEY" {
|
||||
if _, err := x509.ParsePKCS1PrivateKey(block.Bytes); err != nil {
|
||||
return nil, fmt.Errorf("private_key invalid rsa: %w", err)
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
|
||||
if block.Type == "PRIVATE KEY" {
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("private_key invalid pkcs8: %w", err)
|
||||
}
|
||||
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("private_key is not an RSA key")
|
||||
}
|
||||
der := x509.MarshalPKCS1PrivateKey(rsaKey)
|
||||
return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}, nil
|
||||
}
|
||||
|
||||
// Attempt auto-detection: try PKCS#1 first, then PKCS#8.
|
||||
if rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
der := x509.MarshalPKCS1PrivateKey(rsaKey)
|
||||
return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}, nil
|
||||
}
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
if rsaKey, ok := key.(*rsa.PrivateKey); ok {
|
||||
der := x509.MarshalPKCS1PrivateKey(rsaKey)
|
||||
return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("private_key uses unsupported format")
|
||||
}
|
||||
|
||||
func rebuildPEM(raw string) (string, error) {
|
||||
kind := "PRIVATE KEY"
|
||||
if strings.Contains(raw, "RSA PRIVATE KEY") {
|
||||
kind = "RSA PRIVATE KEY"
|
||||
}
|
||||
header := "-----BEGIN " + kind + "-----"
|
||||
footer := "-----END " + kind + "-----"
|
||||
start := strings.Index(raw, header)
|
||||
end := strings.Index(raw, footer)
|
||||
if start < 0 || end <= start {
|
||||
return "", fmt.Errorf("missing pem markers")
|
||||
}
|
||||
body := raw[start+len(header) : end]
|
||||
payload := filterBase64(body)
|
||||
if payload == "" {
|
||||
return "", fmt.Errorf("private_key base64 payload empty")
|
||||
}
|
||||
der, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("private_key base64 decode failed: %w", err)
|
||||
}
|
||||
block := &pem.Block{Type: kind, Bytes: der}
|
||||
return string(pem.EncodeToMemory(block)), nil
|
||||
}
|
||||
|
||||
func filterBase64(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r)
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '+' || r == '/' || r == '=':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func stripANSIEscape(s string) string {
|
||||
in := []rune(s)
|
||||
var out []rune
|
||||
for i := 0; i < len(in); i++ {
|
||||
r := in[i]
|
||||
if r != 0x1b {
|
||||
out = append(out, r)
|
||||
continue
|
||||
}
|
||||
if i+1 >= len(in) {
|
||||
continue
|
||||
}
|
||||
next := in[i+1]
|
||||
switch next {
|
||||
case ']':
|
||||
i += 2
|
||||
for i < len(in) {
|
||||
if in[i] == 0x07 {
|
||||
break
|
||||
}
|
||||
if in[i] == 0x1b && i+1 < len(in) && in[i+1] == '\\' {
|
||||
i++
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
case '[':
|
||||
i += 2
|
||||
for i < len(in) {
|
||||
if (in[i] >= 'A' && in[i] <= 'Z') || (in[i] >= 'a' && in[i] <= 'z') {
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
default:
|
||||
// skip single ESC
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
84
backend/internal/auth/vertex/vertex_credentials.go
Normal file
84
backend/internal/auth/vertex/vertex_credentials.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// Package vertex provides token storage for Google Vertex AI Gemini via service account credentials.
|
||||
// It serialises service account JSON into an auth file that is consumed by the runtime executor.
|
||||
package vertex
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// VertexCredentialStorage stores the service account JSON for Vertex AI access.
|
||||
// The content is persisted verbatim under the "service_account" key, together with
|
||||
// helper fields for project, location and email to improve logging and discovery.
|
||||
type VertexCredentialStorage struct {
|
||||
// ServiceAccount holds the parsed service account JSON content.
|
||||
ServiceAccount map[string]any `json:"service_account"`
|
||||
|
||||
// ProjectID is derived from the service account JSON (project_id).
|
||||
ProjectID string `json:"project_id"`
|
||||
|
||||
// Email is the client_email from the service account JSON.
|
||||
Email string `json:"email"`
|
||||
|
||||
// Location optionally sets a default region (e.g., us-central1) for Vertex endpoints.
|
||||
Location string `json:"location,omitempty"`
|
||||
|
||||
// Type is the provider identifier stored alongside credentials. Always "vertex".
|
||||
Type string `json:"type"`
|
||||
|
||||
// Prefix optionally namespaces models for this credential (e.g., "teamA").
|
||||
// This results in model names like "teamA/gemini-2.0-flash".
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
|
||||
// Metadata holds arbitrary key-value pairs injected via hooks.
|
||||
Metadata map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
// SetMetadata allows external callers to inject metadata into the storage before saving.
|
||||
func (s *VertexCredentialStorage) SetMetadata(meta map[string]any) {
|
||||
s.Metadata = meta
|
||||
}
|
||||
|
||||
// SaveTokenToFile writes the credential payload to the given file path in JSON format.
|
||||
// It ensures the parent directory exists and logs the operation for transparency.
|
||||
func (s *VertexCredentialStorage) SaveTokenToFile(authFilePath string) error {
|
||||
misc.LogSavingCredentials(authFilePath)
|
||||
if s == nil {
|
||||
return fmt.Errorf("vertex credential: storage is nil")
|
||||
}
|
||||
if s.ServiceAccount == nil {
|
||||
return fmt.Errorf("vertex credential: service account content is empty")
|
||||
}
|
||||
// Ensure we tag the file with the provider type.
|
||||
s.Type = "vertex"
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(authFilePath), 0o700); err != nil {
|
||||
return fmt.Errorf("vertex credential: create directory failed: %w", err)
|
||||
}
|
||||
|
||||
data, errMerge := misc.MergeMetadata(s, s.Metadata)
|
||||
if errMerge != nil {
|
||||
return fmt.Errorf("vertex credential: merge metadata failed: %w", errMerge)
|
||||
}
|
||||
|
||||
f, err := os.Create(authFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("vertex credential: create file failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := f.Close(); errClose != nil {
|
||||
log.Errorf("vertex credential: failed to close file: %v", errClose)
|
||||
}
|
||||
}()
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
if err = enc.Encode(data); err != nil {
|
||||
return fmt.Errorf("vertex credential: encode failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
106
backend/internal/auth/xai/token.go
Normal file
106
backend/internal/auth/xai/token.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package xai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// TokenStorage stores xAI OAuth credentials on disk.
|
||||
type TokenStorage struct {
|
||||
Type string `json:"type"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
Expire string `json:"expired,omitempty"`
|
||||
LastRefresh string `json:"last_refresh,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Subject string `json:"sub,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri,omitempty"`
|
||||
TokenEndpoint string `json:"token_endpoint,omitempty"`
|
||||
AuthKind string `json:"auth_kind,omitempty"`
|
||||
|
||||
Metadata map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
// SetMetadata allows the token store to merge status fields before saving.
|
||||
func (ts *TokenStorage) SetMetadata(meta map[string]any) {
|
||||
ts.Metadata = meta
|
||||
}
|
||||
|
||||
// SaveTokenToFile writes xAI credentials to a JSON auth file.
|
||||
func (ts *TokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
misc.LogSavingCredentials(authFilePath)
|
||||
ts.Type = "xai"
|
||||
ts.AuthKind = "oauth"
|
||||
if errMkdirAll := os.MkdirAll(filepath.Dir(authFilePath), 0o700); errMkdirAll != nil {
|
||||
return fmt.Errorf("xai token storage: create directory: %w", errMkdirAll)
|
||||
}
|
||||
|
||||
data, errMerge := misc.MergeMetadata(ts, ts.Metadata)
|
||||
if errMerge != nil {
|
||||
return fmt.Errorf("xai token storage: merge metadata: %w", errMerge)
|
||||
}
|
||||
|
||||
file, err := os.Create(authFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("xai token storage: create token file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := file.Close(); errClose != nil {
|
||||
log.Errorf("xai token storage: close token file error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
if err = encoder.Encode(data); err != nil {
|
||||
return fmt.Errorf("xai token storage: write token file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CredentialFileName returns the filename used for xAI credentials.
|
||||
func CredentialFileName(email, subject string) string {
|
||||
email = sanitizeFileSegment(email)
|
||||
if email != "" {
|
||||
return fmt.Sprintf("xai-%s.json", email)
|
||||
}
|
||||
subject = sanitizeFileSegment(subject)
|
||||
if subject != "" {
|
||||
return fmt.Sprintf("xai-%s.json", subject)
|
||||
}
|
||||
return fmt.Sprintf("xai-%d.json", time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
func sanitizeFileSegment(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '@' || r == '.' || r == '_' || r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
75
backend/internal/auth/xai/types.go
Normal file
75
backend/internal/auth/xai/types.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// Package xai provides OAuth2 authentication helpers for xAI Grok.
|
||||
package xai
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// DefaultAPIBaseURL is the default official xAI API base URL.
|
||||
// Used for OAuth credential defaults, websocket, media (image/video),
|
||||
// and non-media HTTP chat when auth using_api is true or non-OAuth.
|
||||
DefaultAPIBaseURL = "https://api.x.ai/v1"
|
||||
// CLIChatProxyBaseURL is the Grok CLI chat-proxy base URL for non-image/video
|
||||
// HTTP chat when auth using_api is false, including the OAuth default.
|
||||
CLIChatProxyBaseURL = "https://cli-chat-proxy.grok.com/v1"
|
||||
// Issuer is xAI's OAuth issuer.
|
||||
Issuer = "https://auth.x.ai"
|
||||
// DiscoveryURL is the OIDC discovery endpoint used to resolve OAuth endpoints.
|
||||
DiscoveryURL = Issuer + "/.well-known/openid-configuration"
|
||||
// ClientID is the public xAI Grok CLI OAuth client ID.
|
||||
ClientID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
// Scope is the OAuth scope set required for xAI API access.
|
||||
Scope = "openid profile email offline_access grok-cli:access api:access"
|
||||
// DeviceCodeGrantType is the OAuth2 device authorization grant type (RFC 8628).
|
||||
DeviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
// defaultPollInterval is used when the device endpoint omits interval.
|
||||
defaultPollInterval = 5 * time.Second
|
||||
// httpClientTimeout bounds credential-acquisition HTTP calls (device/token/refresh).
|
||||
httpClientTimeout = 30 * time.Second
|
||||
// MaxPollDuration is the upper bound for waiting on user authorization.
|
||||
MaxPollDuration = 30 * time.Minute
|
||||
)
|
||||
|
||||
var refreshLead = 5 * time.Minute
|
||||
|
||||
// RefreshLead returns the refresh lead time for xAI OAuth credentials.
|
||||
func RefreshLead() time.Duration {
|
||||
return refreshLead
|
||||
}
|
||||
|
||||
// Discovery contains OAuth endpoints resolved from xAI OIDC discovery.
|
||||
type Discovery struct {
|
||||
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
}
|
||||
|
||||
// DeviceCodeResponse represents xAI's device authorization response.
|
||||
type DeviceCodeResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
TokenEndpoint string `json:"-"`
|
||||
}
|
||||
|
||||
// TokenData holds xAI OAuth token data.
|
||||
type TokenData struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
Expire string `json:"expired,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Subject string `json:"sub,omitempty"`
|
||||
}
|
||||
|
||||
// AuthBundle aggregates token data and OAuth metadata for persistence.
|
||||
type AuthBundle struct {
|
||||
TokenData TokenData
|
||||
LastRefresh string
|
||||
BaseURL string
|
||||
RedirectURI string
|
||||
TokenEndpoint string
|
||||
}
|
||||
483
backend/internal/auth/xai/xai.go
Normal file
483
backend/internal/auth/xai/xai.go
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
package xai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// XAIAuth performs xAI OAuth discovery, device-code login, and refresh.
|
||||
type XAIAuth struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var xaiRefreshGroup singleflight.Group
|
||||
|
||||
// NewXAIAuth creates an xAI OAuth helper using config proxy settings.
|
||||
func NewXAIAuth(cfg *config.Config) *XAIAuth {
|
||||
return NewXAIAuthWithProxyURL(cfg, "")
|
||||
}
|
||||
|
||||
// NewXAIAuthWithProxyURL creates an xAI OAuth helper with an explicit proxy URL.
|
||||
func NewXAIAuthWithProxyURL(cfg *config.Config, proxyURL string) *XAIAuth {
|
||||
effectiveProxyURL := strings.TrimSpace(proxyURL)
|
||||
var sdkCfg config.SDKConfig
|
||||
if cfg != nil {
|
||||
sdkCfg = cfg.SDKConfig
|
||||
if effectiveProxyURL == "" {
|
||||
effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL)
|
||||
}
|
||||
}
|
||||
sdkCfg.ProxyURL = effectiveProxyURL
|
||||
return &XAIAuth{httpClient: util.SetProxy(&sdkCfg, &http.Client{Timeout: httpClientTimeout})}
|
||||
}
|
||||
|
||||
// ValidateOAuthEndpoint validates an endpoint returned by xAI discovery.
|
||||
func ValidateOAuthEndpoint(rawURL string, field string) (string, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return "", fmt.Errorf("xai discovery %s is empty", field)
|
||||
}
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("xai discovery %s is invalid: %w", field, err)
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return "", fmt.Errorf("xai discovery %s must use https: %q", field, rawURL)
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
||||
if host != "x.ai" && !strings.HasSuffix(host, ".x.ai") {
|
||||
return "", fmt.Errorf("xai discovery %s host %q is not on x.ai", field, host)
|
||||
}
|
||||
return rawURL, nil
|
||||
}
|
||||
|
||||
// Discover resolves xAI OAuth endpoints through OIDC discovery.
|
||||
func (a *XAIAuth) Discover(ctx context.Context) (*Discovery, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, DiscoveryURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai discovery: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai discovery: request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("xai discovery: close response body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai discovery: read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("xai discovery failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var payload struct {
|
||||
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, fmt.Errorf("xai discovery: parse response: %w", err)
|
||||
}
|
||||
deviceAuthorizationEndpoint, err := ValidateOAuthEndpoint(payload.DeviceAuthorizationEndpoint, "device_authorization_endpoint")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenEndpoint, err := ValidateOAuthEndpoint(payload.TokenEndpoint, "token_endpoint")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Discovery{
|
||||
DeviceAuthorizationEndpoint: deviceAuthorizationEndpoint,
|
||||
TokenEndpoint: tokenEndpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StartDeviceFlow requests a device code from xAI.
|
||||
func (a *XAIAuth) StartDeviceFlow(ctx context.Context) (*DeviceCodeResponse, error) {
|
||||
discovery, errDiscover := a.Discover(ctx)
|
||||
if errDiscover != nil {
|
||||
return nil, errDiscover
|
||||
}
|
||||
return a.RequestDeviceCode(ctx, discovery.DeviceAuthorizationEndpoint, discovery.TokenEndpoint)
|
||||
}
|
||||
|
||||
// RequestDeviceCode requests a device authorization code from the given endpoint.
|
||||
func (a *XAIAuth) RequestDeviceCode(ctx context.Context, deviceAuthorizationEndpoint, tokenEndpoint string) (*DeviceCodeResponse, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
deviceAuthorizationEndpoint = strings.TrimSpace(deviceAuthorizationEndpoint)
|
||||
if deviceAuthorizationEndpoint == "" {
|
||||
return nil, fmt.Errorf("xai device code: device authorization endpoint is required")
|
||||
}
|
||||
|
||||
form := url.Values{
|
||||
"client_id": {ClientID},
|
||||
"scope": {Scope},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, deviceAuthorizationEndpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai device code: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai device code request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("xai device code: close response body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai device code: read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("xai device code request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var deviceCode DeviceCodeResponse
|
||||
if err = json.Unmarshal(body, &deviceCode); err != nil {
|
||||
return nil, fmt.Errorf("xai device code: parse response: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(deviceCode.DeviceCode) == "" {
|
||||
return nil, fmt.Errorf("xai device code: response missing device_code")
|
||||
}
|
||||
if strings.TrimSpace(deviceCode.UserCode) == "" {
|
||||
return nil, fmt.Errorf("xai device code: response missing user_code")
|
||||
}
|
||||
if strings.TrimSpace(deviceCode.VerificationURI) == "" && strings.TrimSpace(deviceCode.VerificationURIComplete) == "" {
|
||||
return nil, fmt.Errorf("xai device code: response missing verification URI")
|
||||
}
|
||||
deviceCode.TokenEndpoint = strings.TrimSpace(tokenEndpoint)
|
||||
return &deviceCode, nil
|
||||
}
|
||||
|
||||
// WaitForAuthorization polls until the user authorizes the device code and returns tokens.
|
||||
func (a *XAIAuth) WaitForAuthorization(ctx context.Context, deviceCode *DeviceCodeResponse) (*AuthBundle, error) {
|
||||
tokenData, err := a.PollForToken(ctx, deviceCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenEndpoint := ""
|
||||
if deviceCode != nil {
|
||||
tokenEndpoint = strings.TrimSpace(deviceCode.TokenEndpoint)
|
||||
}
|
||||
return &AuthBundle{
|
||||
TokenData: *tokenData,
|
||||
LastRefresh: time.Now().UTC().Format(time.RFC3339),
|
||||
BaseURL: DefaultAPIBaseURL,
|
||||
TokenEndpoint: tokenEndpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PollForToken polls the token endpoint until the user authorizes or the device code expires.
|
||||
func (a *XAIAuth) PollForToken(ctx context.Context, deviceCode *DeviceCodeResponse) (*TokenData, error) {
|
||||
if deviceCode == nil {
|
||||
return nil, fmt.Errorf("xai device code: response is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
tokenEndpoint := strings.TrimSpace(deviceCode.TokenEndpoint)
|
||||
if tokenEndpoint == "" {
|
||||
discovery, errDiscover := a.Discover(ctx)
|
||||
if errDiscover != nil {
|
||||
return nil, errDiscover
|
||||
}
|
||||
tokenEndpoint = discovery.TokenEndpoint
|
||||
}
|
||||
|
||||
interval := time.Duration(deviceCode.Interval) * time.Second
|
||||
if interval < defaultPollInterval {
|
||||
interval = defaultPollInterval
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(MaxPollDuration)
|
||||
if deviceCode.ExpiresIn > 0 {
|
||||
codeDeadline := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second)
|
||||
if codeDeadline.Before(deadline) {
|
||||
deadline = codeDeadline
|
||||
}
|
||||
}
|
||||
|
||||
// Poll immediately once, then wait between subsequent attempts.
|
||||
firstAttempt := true
|
||||
timer := time.NewTimer(0)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("xai device code: context cancelled: %w", ctx.Err())
|
||||
case <-timer.C:
|
||||
if !firstAttempt && time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("xai device code expired")
|
||||
}
|
||||
firstAttempt = false
|
||||
|
||||
token, pollErr, nextInterval, shouldContinue := a.exchangeDeviceCode(ctx, tokenEndpoint, deviceCode.DeviceCode, interval)
|
||||
if token != nil {
|
||||
return token, nil
|
||||
}
|
||||
if !shouldContinue {
|
||||
return nil, pollErr
|
||||
}
|
||||
interval = nextInterval
|
||||
timer.Reset(interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// exchangeDeviceCode attempts to exchange a device code for tokens.
|
||||
// Returns (token, error, nextInterval, shouldContinue).
|
||||
func (a *XAIAuth) exchangeDeviceCode(ctx context.Context, tokenEndpoint, deviceCode string, interval time.Duration) (*TokenData, error, time.Duration, bool) {
|
||||
form := url.Values{
|
||||
"grant_type": {DeviceCodeGrantType},
|
||||
"device_code": {strings.TrimSpace(deviceCode)},
|
||||
"client_id": {ClientID},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(tokenEndpoint), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai device token: create request: %w", err), interval, false
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai device token request failed: %w", err), interval, false
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("xai device token: close response body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai device token: read response: %w", err), interval, false
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, fmt.Errorf("xai device token: parse response: %w", err), interval, false
|
||||
}
|
||||
|
||||
if payload.Error != "" {
|
||||
switch payload.Error {
|
||||
case "authorization_pending":
|
||||
return nil, nil, interval, true
|
||||
case "slow_down":
|
||||
nextInterval := interval + defaultPollInterval
|
||||
return nil, nil, nextInterval, true
|
||||
case "expired_token":
|
||||
return nil, fmt.Errorf("xai device code expired"), interval, false
|
||||
case "access_denied":
|
||||
return nil, fmt.Errorf("xai device authorization denied"), interval, false
|
||||
default:
|
||||
desc := strings.TrimSpace(payload.ErrorDescription)
|
||||
if desc != "" {
|
||||
return nil, fmt.Errorf("xai device token error: %s: %s", payload.Error, desc), interval, false
|
||||
}
|
||||
return nil, fmt.Errorf("xai device token error: %s", payload.Error), interval, false
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("xai device token request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))), interval, false
|
||||
}
|
||||
if strings.TrimSpace(payload.AccessToken) == "" {
|
||||
return nil, fmt.Errorf("xai device token response missing access_token"), interval, false
|
||||
}
|
||||
|
||||
email, subject := parseJWTIdentity(payload.IDToken)
|
||||
return buildTokenData(payload.AccessToken, payload.RefreshToken, payload.IDToken, payload.TokenType, payload.ExpiresIn, email, subject), nil, interval, false
|
||||
}
|
||||
|
||||
// RefreshTokens refreshes an xAI access token.
|
||||
func (a *XAIAuth) RefreshTokens(ctx context.Context, refreshToken, tokenEndpoint string) (*TokenData, error) {
|
||||
if strings.TrimSpace(refreshToken) == "" {
|
||||
return nil, fmt.Errorf("xai token refresh: refresh token is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
refreshToken = strings.TrimSpace(refreshToken)
|
||||
if strings.TrimSpace(tokenEndpoint) == "" {
|
||||
discovery, errDiscover := a.Discover(ctx)
|
||||
if errDiscover != nil {
|
||||
return nil, errDiscover
|
||||
}
|
||||
tokenEndpoint = discovery.TokenEndpoint
|
||||
}
|
||||
tokenEndpoint = strings.TrimSpace(tokenEndpoint)
|
||||
|
||||
result, err, _ := xaiRefreshGroup.Do(refreshToken, func() (interface{}, error) {
|
||||
return a.refreshTokensSingleFlight(context.WithoutCancel(ctx), refreshToken, tokenEndpoint)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenData, ok := result.(*TokenData)
|
||||
if !ok || tokenData == nil {
|
||||
return nil, fmt.Errorf("xai token refresh failed: invalid single-flight result")
|
||||
}
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
func (a *XAIAuth) refreshTokensSingleFlight(ctx context.Context, refreshToken, tokenEndpoint string) (*TokenData, error) {
|
||||
form := url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"client_id": {ClientID},
|
||||
"refresh_token": {refreshToken},
|
||||
}
|
||||
return a.postTokenForm(ctx, tokenEndpoint, form)
|
||||
}
|
||||
|
||||
func (a *XAIAuth) postTokenForm(ctx context.Context, tokenEndpoint string, form url.Values) (*TokenData, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(tokenEndpoint), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai token request: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai token request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("xai token request: close response body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai token response: read body: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("xai token request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var payload struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, fmt.Errorf("xai token response: parse body: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(payload.AccessToken) == "" {
|
||||
return nil, fmt.Errorf("xai token response missing access_token")
|
||||
}
|
||||
email, subject := parseJWTIdentity(payload.IDToken)
|
||||
return buildTokenData(payload.AccessToken, payload.RefreshToken, payload.IDToken, payload.TokenType, payload.ExpiresIn, email, subject), nil
|
||||
}
|
||||
|
||||
// CreateTokenStorage converts an auth bundle into persistable storage.
|
||||
func (a *XAIAuth) CreateTokenStorage(bundle *AuthBundle) *TokenStorage {
|
||||
if bundle == nil {
|
||||
return nil
|
||||
}
|
||||
return &TokenStorage{
|
||||
Type: "xai",
|
||||
AccessToken: bundle.TokenData.AccessToken,
|
||||
RefreshToken: bundle.TokenData.RefreshToken,
|
||||
IDToken: bundle.TokenData.IDToken,
|
||||
TokenType: bundle.TokenData.TokenType,
|
||||
ExpiresIn: bundle.TokenData.ExpiresIn,
|
||||
Expire: bundle.TokenData.Expire,
|
||||
LastRefresh: bundle.LastRefresh,
|
||||
Email: strings.TrimSpace(bundle.TokenData.Email),
|
||||
Subject: bundle.TokenData.Subject,
|
||||
BaseURL: firstNonEmpty(bundle.BaseURL, DefaultAPIBaseURL),
|
||||
RedirectURI: bundle.RedirectURI,
|
||||
TokenEndpoint: bundle.TokenEndpoint,
|
||||
AuthKind: "oauth",
|
||||
}
|
||||
}
|
||||
|
||||
func buildTokenData(accessToken, refreshToken, idToken, tokenType string, expiresIn int, email, subject string) *TokenData {
|
||||
tokenData := &TokenData{
|
||||
AccessToken: strings.TrimSpace(accessToken),
|
||||
RefreshToken: strings.TrimSpace(refreshToken),
|
||||
IDToken: strings.TrimSpace(idToken),
|
||||
TokenType: strings.TrimSpace(tokenType),
|
||||
ExpiresIn: expiresIn,
|
||||
Email: email,
|
||||
Subject: subject,
|
||||
}
|
||||
if expiresIn > 0 {
|
||||
tokenData.Expire = time.Now().Add(time.Duration(expiresIn) * time.Second).UTC().Format(time.RFC3339)
|
||||
}
|
||||
return tokenData
|
||||
}
|
||||
|
||||
func parseJWTIdentity(token string) (email string, subject string) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return "", ""
|
||||
}
|
||||
payload := parts[1]
|
||||
payload += strings.Repeat("=", (4-len(payload)%4)%4)
|
||||
raw, err := base64.URLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
var claims map[string]any
|
||||
if err = json.Unmarshal(raw, &claims); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
if v, ok := claims["email"].(string); ok {
|
||||
email = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := claims["sub"].(string); ok {
|
||||
subject = strings.TrimSpace(v)
|
||||
}
|
||||
return email, subject
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
327
backend/internal/auth/xai/xai_auth_test.go
Normal file
327
backend/internal/auth/xai/xai_auth_test.go
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
package xai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
func resetXAIRefreshGroupForTest() {
|
||||
xaiRefreshGroup = singleflight.Group{}
|
||||
}
|
||||
|
||||
func TestValidateOAuthEndpointRejectsNonXAIOrigin(t *testing.T) {
|
||||
if _, err := ValidateOAuthEndpoint("https://auth.x.ai/oauth2/token", "token_endpoint"); err != nil {
|
||||
t.Fatalf("ValidateOAuthEndpoint(xai) error = %v", err)
|
||||
}
|
||||
if _, err := ValidateOAuthEndpoint("http://auth.x.ai/oauth2/token", "token_endpoint"); err == nil {
|
||||
t.Fatal("expected non-HTTPS endpoint to be rejected")
|
||||
}
|
||||
if _, err := ValidateOAuthEndpoint("https://evil.example/oauth/token", "token_endpoint"); err == nil {
|
||||
t.Fatal("expected non-xAI endpoint to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDeviceCodePostsClientIDAndScope(t *testing.T) {
|
||||
var gotForm url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/x-www-form-urlencoded") {
|
||||
t.Fatalf("Content-Type = %q, want form", got)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm() error = %v", err)
|
||||
}
|
||||
gotForm = r.PostForm
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"device_code": "device-abc",
|
||||
"user_code": "ABCD-1234",
|
||||
"verification_uri": "https://accounts.x.ai/oauth2/device",
|
||||
"verification_uri_complete": "https://accounts.x.ai/oauth2/device?user_code=ABCD-1234",
|
||||
"expires_in": 1800,
|
||||
"interval": 5,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
auth := NewXAIAuth(nil)
|
||||
deviceCode, err := auth.RequestDeviceCode(context.Background(), server.URL, "https://auth.x.ai/oauth2/token")
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceCode() error = %v", err)
|
||||
}
|
||||
if deviceCode.DeviceCode != "device-abc" {
|
||||
t.Fatalf("device_code = %q, want device-abc", deviceCode.DeviceCode)
|
||||
}
|
||||
if deviceCode.UserCode != "ABCD-1234" {
|
||||
t.Fatalf("user_code = %q, want ABCD-1234", deviceCode.UserCode)
|
||||
}
|
||||
if deviceCode.TokenEndpoint != "https://auth.x.ai/oauth2/token" {
|
||||
t.Fatalf("TokenEndpoint = %q", deviceCode.TokenEndpoint)
|
||||
}
|
||||
if gotForm.Get("client_id") != ClientID {
|
||||
t.Fatalf("client_id = %q, want %q", gotForm.Get("client_id"), ClientID)
|
||||
}
|
||||
if gotForm.Get("scope") != Scope {
|
||||
t.Fatalf("scope = %q, want %q", gotForm.Get("scope"), Scope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollForTokenExchangesDeviceCode(t *testing.T) {
|
||||
var pollCount int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm() error = %v", err)
|
||||
}
|
||||
if got := r.PostForm.Get("grant_type"); got != DeviceCodeGrantType {
|
||||
t.Fatalf("grant_type = %q, want %q", got, DeviceCodeGrantType)
|
||||
}
|
||||
if got := r.PostForm.Get("device_code"); got != "device-abc" {
|
||||
t.Fatalf("device_code = %q, want device-abc", got)
|
||||
}
|
||||
if got := r.PostForm.Get("client_id"); got != ClientID {
|
||||
t.Fatalf("client_id = %q, want %q", got, ClientID)
|
||||
}
|
||||
|
||||
count := atomic.AddInt32(&pollCount, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if count == 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "authorization_pending",
|
||||
"error_description": "User has not yet authorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "access-1",
|
||||
"refresh_token": "refresh-1",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"id_token": fakeJWTWithEmail("user@x.ai", "sub-1"),
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
auth := NewXAIAuth(nil)
|
||||
tokenData, err := auth.PollForToken(context.Background(), &DeviceCodeResponse{
|
||||
DeviceCode: "device-abc",
|
||||
UserCode: "ABCD-1234",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
TokenEndpoint: server.URL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PollForToken() error = %v", err)
|
||||
}
|
||||
if tokenData.AccessToken != "access-1" {
|
||||
t.Fatalf("access token = %q, want access-1", tokenData.AccessToken)
|
||||
}
|
||||
if tokenData.RefreshToken != "refresh-1" {
|
||||
t.Fatalf("refresh token = %q, want refresh-1", tokenData.RefreshToken)
|
||||
}
|
||||
if tokenData.Email != "user@x.ai" {
|
||||
t.Fatalf("email = %q, want user@x.ai", tokenData.Email)
|
||||
}
|
||||
if tokenData.Subject != "sub-1" {
|
||||
t.Fatalf("subject = %q, want sub-1", tokenData.Subject)
|
||||
}
|
||||
if got := atomic.LoadInt32(&pollCount); got != 2 {
|
||||
t.Fatalf("poll count = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollForTokenAccessDenied(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "access_denied",
|
||||
"error_description": "The user rejected the request",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
auth := NewXAIAuth(nil)
|
||||
_, err := auth.PollForToken(context.Background(), &DeviceCodeResponse{
|
||||
DeviceCode: "device-abc",
|
||||
UserCode: "ABCD-1234",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
TokenEndpoint: server.URL,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "authorization denied") {
|
||||
t.Fatalf("PollForToken() error = %v, want authorization denied", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollForTokenSlowDownContinuesPolling(t *testing.T) {
|
||||
var pollCount int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
count := atomic.AddInt32(&pollCount, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if count == 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "slow_down"})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "access-slow",
|
||||
"refresh_token": "refresh-slow",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
auth := NewXAIAuth(nil)
|
||||
tokenData, err := auth.PollForToken(context.Background(), &DeviceCodeResponse{
|
||||
DeviceCode: "device-abc",
|
||||
UserCode: "ABCD-1234",
|
||||
ExpiresIn: 60,
|
||||
Interval: 5,
|
||||
TokenEndpoint: server.URL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PollForToken() error = %v", err)
|
||||
}
|
||||
if tokenData.AccessToken != "access-slow" {
|
||||
t.Fatalf("access token = %q, want access-slow", tokenData.AccessToken)
|
||||
}
|
||||
if got := atomic.LoadInt32(&pollCount); got != 2 {
|
||||
t.Fatalf("poll count = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTokenDataOmitsExpireWhenExpiresInZero(t *testing.T) {
|
||||
tokenData := buildTokenData("access", "refresh", "", "Bearer", 0, "user@x.ai", "sub-1")
|
||||
if tokenData.Expire != "" {
|
||||
t.Fatalf("Expire = %q, want empty", tokenData.Expire)
|
||||
}
|
||||
tokenData = buildTokenData("access", "refresh", "", "Bearer", 60, "user@x.ai", "sub-1")
|
||||
if tokenData.Expire == "" {
|
||||
t.Fatal("Expire empty, want RFC3339 timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokensPostsClientIDAndRefreshToken(t *testing.T) {
|
||||
var gotForm url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/x-www-form-urlencoded") {
|
||||
t.Fatalf("Content-Type = %q, want form", got)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm() error = %v", err)
|
||||
}
|
||||
gotForm = r.PostForm
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "new-access",
|
||||
"refresh_token": "new-refresh",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
auth := NewXAIAuth(nil)
|
||||
tokenData, err := auth.RefreshTokens(context.Background(), "old-refresh", server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshTokens() error = %v", err)
|
||||
}
|
||||
if tokenData.AccessToken != "new-access" {
|
||||
t.Fatalf("access token = %q, want new-access", tokenData.AccessToken)
|
||||
}
|
||||
if gotForm.Get("grant_type") != "refresh_token" {
|
||||
t.Fatalf("grant_type = %q, want refresh_token", gotForm.Get("grant_type"))
|
||||
}
|
||||
if gotForm.Get("client_id") != ClientID {
|
||||
t.Fatalf("client_id = %q, want %q", gotForm.Get("client_id"), ClientID)
|
||||
}
|
||||
if gotForm.Get("refresh_token") != "old-refresh" {
|
||||
t.Fatalf("refresh_token = %q, want old-refresh", gotForm.Get("refresh_token"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokens_DeduplicatesConcurrentRefresh(t *testing.T) {
|
||||
resetXAIRefreshGroupForTest()
|
||||
t.Cleanup(resetXAIRefreshGroupForTest)
|
||||
|
||||
var calls int32
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
once.Do(func() { close(started) })
|
||||
<-release
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "new-access",
|
||||
"refresh_token": "new-refresh",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
authA := NewXAIAuth(nil)
|
||||
authB := NewXAIAuth(nil)
|
||||
results := make(chan *TokenData, 2)
|
||||
errs := make(chan error, 2)
|
||||
runRefresh := func(auth *XAIAuth, launched chan<- struct{}) {
|
||||
if launched != nil {
|
||||
close(launched)
|
||||
}
|
||||
tokenData, errRefresh := auth.RefreshTokens(context.Background(), "shared-refresh-token", server.URL)
|
||||
results <- tokenData
|
||||
errs <- errRefresh
|
||||
}
|
||||
|
||||
go runRefresh(authA, nil)
|
||||
<-started
|
||||
|
||||
secondLaunched := make(chan struct{})
|
||||
go runRefresh(authB, secondLaunched)
|
||||
<-secondLaunched
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected concurrent refresh to share a single upstream call, got %d", got)
|
||||
}
|
||||
close(release)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if errRefresh := <-errs; errRefresh != nil {
|
||||
t.Fatalf("expected refresh to succeed, got %v", errRefresh)
|
||||
}
|
||||
tokenData := <-results
|
||||
if tokenData == nil || tokenData.AccessToken != "new-access" || tokenData.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("unexpected token data: %#v", tokenData)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected both refresh callers to share a single upstream call, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func fakeJWTWithEmail(email, subject string) string {
|
||||
header := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
||||
payload := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(`{"email":"` + email + `","sub":"` + subject + `"}`))
|
||||
return header + "." + payload + ".sig"
|
||||
}
|
||||
Loading…
Reference in a new issue