Index: internal/oauth/claude/oauth.go --- internal/oauth/claude/oauth.go.orig +++ internal/oauth/claude/oauth.go @@ -0,0 +1,131 @@ +package claude + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/charmbracelet/crush/internal/oauth" +) + +const ( + clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + tokenURL = "https://platform.claude.com/v1/oauth/token" + redirectURI = "https://platform.claude.com/oauth/code/callback" +) + +// AuthorizeURL returns the Claude Code Max OAuth2 authorization URL. +func AuthorizeURL(verifier, challenge string) (string, error) { + u, err := url.Parse("https://platform.claude.com/oauth/authorize") + if err != nil { + return "", err + } + q := u.Query() + q.Set("code", "true") + q.Set("client_id", clientId) + q.Set("response_type", "code") + q.Set("redirect_uri", redirectURI) + q.Set("scope", "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload") + q.Set("code_challenge", challenge) + q.Set("code_challenge_method", "S256") + q.Set("state", verifier) + u.RawQuery = q.Encode() + return u.String(), nil +} + +// ExchangeToken exchanges the authorization code for an OAuth2 token. +func ExchangeToken(ctx context.Context, code, verifier string) (*oauth.Token, error) { + code = strings.TrimSpace(code) + parts := strings.SplitN(code, "#", 2) + pure := parts[0] + state := "" + if len(parts) > 1 { + state = parts[1] + } + + reqBody := map[string]string{ + "code": pure, + "state": state, + "grant_type": "authorization_code", + "client_id": clientId, + "redirect_uri": redirectURI, + "code_verifier": verifier, + } + + resp, err := request(ctx, "POST", tokenURL, reqBody) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("claude code max: failed to exchange token: status %d body %q", resp.StatusCode, string(body)) + } + + var token oauth.Token + if err := json.Unmarshal(body, &token); err != nil { + return nil, err + } + token.SetExpiresAt() + return &token, nil +} + +// RefreshToken refreshes the OAuth2 token using the provided refresh token. +func RefreshToken(ctx context.Context, refreshToken string) (*oauth.Token, error) { + reqBody := map[string]string{ + "grant_type": "refresh_token", + "refresh_token": refreshToken, + "client_id": clientId, + } + + resp, err := request(ctx, "POST", tokenURL, reqBody) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("claude code max: failed to refresh token: status %d body %q", resp.StatusCode, string(body)) + } + + var token oauth.Token + if err := json.Unmarshal(body, &token); err != nil { + return nil, err + } + token.SetExpiresAt() + return &token, nil +} + +func request(ctx context.Context, method, url string, body any) (*http.Response, error) { + date, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(date)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "claude-code/2.1.112") + + client := &http.Client{Timeout: 30 * time.Second} + return client.Do(req) +}