1package claude
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "net/url"
11 "strings"
12 "time"
13
14 "github.com/charmbracelet/crush/internal/oauth"
15)
16
17const clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
18
19// AuthorizeURL returns the Claude Code Max OAuth2 authorization URL.
20func AuthorizeURL(verifier, challenge string) (string, error) {
21 u, err := url.Parse("https://claude.ai/oauth/authorize")
22 if err != nil {
23 return "", err
24 }
25 q := u.Query()
26 q.Set("response_type", "code")
27 q.Set("client_id", clientId)
28 q.Set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
29 q.Set("scope", "org:create_api_key user:profile user:inference")
30 q.Set("code_challenge", challenge)
31 q.Set("code_challenge_method", "S256")
32 q.Set("state", verifier)
33 u.RawQuery = q.Encode()
34 return u.String(), nil
35}
36
37// ExchangeToken exchanges the authorization code for an OAuth2 token.
38func ExchangeToken(ctx context.Context, code, verifier string) (*oauth.Token, error) {
39 code = strings.TrimSpace(code)
40 parts := strings.SplitN(code, "#", 2)
41 pure := parts[0]
42 state := ""
43 if len(parts) > 1 {
44 state = parts[1]
45 }
46
47 reqBody := map[string]string{
48 "code": pure,
49 "state": state,
50 "grant_type": "authorization_code",
51 "client_id": clientId,
52 "redirect_uri": "https://console.anthropic.com/oauth/code/callback",
53 "code_verifier": verifier,
54 }
55
56 resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody)
57 if err != nil {
58 return nil, err
59 }
60 defer resp.Body.Close()
61
62 body, err := io.ReadAll(resp.Body)
63 if err != nil {
64 return nil, err
65 }
66
67 if resp.StatusCode != http.StatusOK {
68 return nil, fmt.Errorf("claude code max: failed to exchange token: status %d body %q", resp.StatusCode, string(body))
69 }
70
71 var token oauth.Token
72 if err := json.Unmarshal(body, &token); err != nil {
73 return nil, err
74 }
75 token.SetExpiresAt()
76 return &token, nil
77}
78
79// RefreshToken refreshes the OAuth2 token using the provided refresh token.
80func RefreshToken(ctx context.Context, refreshToken string) (*oauth.Token, error) {
81 reqBody := map[string]string{
82 "grant_type": "refresh_token",
83 "refresh_token": refreshToken,
84 "client_id": clientId,
85 }
86
87 resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody)
88 if err != nil {
89 return nil, err
90 }
91 defer resp.Body.Close()
92
93 body, err := io.ReadAll(resp.Body)
94 if err != nil {
95 return nil, err
96 }
97
98 if resp.StatusCode != http.StatusOK {
99 return nil, fmt.Errorf("claude code max: failed to refresh token: status %d body %q", resp.StatusCode, string(body))
100 }
101
102 var token oauth.Token
103 if err := json.Unmarshal(body, &token); err != nil {
104 return nil, err
105 }
106 token.SetExpiresAt()
107 return &token, nil
108}
109
110func request(ctx context.Context, method, url string, body any) (*http.Response, error) {
111 date, err := json.Marshal(body)
112 if err != nil {
113 return nil, err
114 }
115
116 req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(date))
117 if err != nil {
118 return nil, err
119 }
120
121 req.Header.Set("Content-Type", "application/json")
122 req.Header.Set("User-Agent", "anthropic")
123
124 client := &http.Client{Timeout: 30 * time.Second}
125 return client.Do(req)
126}