oauth.go

  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}