--- /dev/null
+++ internal/oauth/claude/oauth.go
@@ -0,0 +1,126 @@
+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"
+
+// AuthorizeURL returns the Claude Code Max OAuth2 authorization URL.
+func AuthorizeURL(verifier, challenge string) (string, error) {
+	u, err := url.Parse("https://claude.ai/oauth/authorize")
+	if err != nil {
+		return "", err
+	}
+	q := u.Query()
+	q.Set("response_type", "code")
+	q.Set("client_id", clientId)
+	q.Set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
+	q.Set("scope", "org:create_api_key user:profile user:inference")
+	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":  "https://console.anthropic.com/oauth/code/callback",
+		"code_verifier": verifier,
+	}
+
+	resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", 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", "https://console.anthropic.com/v1/oauth/token", 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", "anthropic")
+
+	client := &http.Client{Timeout: 30 * time.Second}
+	return client.Do(req)
+}
