1package auth
2
3import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/base64"
8 "encoding/hex"
9 "encoding/json"
10 "fmt"
11 "io"
12 "net/http"
13 "net/url"
14 "strings"
15 "time"
16)
17
18// OAuthProvider configures a single subscription OAuth flow.
19//
20// WARNING: the client ids used here belong to the official first-party
21// CLIs (Claude Code for Anthropic, Codex CLI for OpenAI). Using them from
22// third-party software is against the respective terms of service and
23// may be revoked at any time. zut exposes this only behind
24// --experimental-oauth.
25type OAuthProvider struct {
26 Name string
27 AuthURL string
28 TokenURL string
29 ClientID string
30 Scopes []string
31 RedirectHost string // usually "localhost"
32 RedirectPort int // must match the client registration at the provider
33 RedirectPath string // e.g. "/callback" or "/auth/callback"
34 ExtraAuthArgs map[string]string
35 ExtraTokenArgs map[string]string
36 // TokenBodyJSON selects JSON body vs application/x-www-form-urlencoded
37 // for token requests. Anthropic requires JSON; OpenAI requires form.
38 TokenBodyJSON bool
39 // StateEqualsVerifier: if true, the OAuth state parameter is set to the
40 // PKCE verifier instead of a random value (Anthropic's Claude Code does this).
41 StateEqualsVerifier bool
42 // IncludeStateInTokenRequest: if true, the state value is included in
43 // the token-exchange payload (Anthropic requires this; OpenAI does not).
44 IncludeStateInTokenRequest bool
45}
46
47// RedirectURI returns the full redirect URI for this provider. For the
48// manual variants (no local callback server) RedirectPath is already an
49// absolute https URL, in which case it's returned as-is.
50func (p OAuthProvider) RedirectURI() string {
51 if p.RedirectHost == "" && (strings.HasPrefix(p.RedirectPath, "http://") || strings.HasPrefix(p.RedirectPath, "https://")) {
52 return p.RedirectPath
53 }
54 return fmt.Sprintf("http://%s:%d%s", p.RedirectHost, p.RedirectPort, p.RedirectPath)
55}
56
57// Built-in providers. Values mirror Anthropic's Claude Code CLI and
58// OpenAI's Codex CLI. Changing any of these fields will most likely
59// cause the flow to fail with redirect_uri_mismatch or invalid_client.
60var (
61 // Anthropic Claude Pro/Max: used by the Claude Code CLI.
62 AnthropicOAuth = OAuthProvider{
63 Name: "anthropic",
64 AuthURL: "https://claude.ai/oauth/authorize",
65 TokenURL: "https://platform.claude.com/v1/oauth/token",
66 ClientID: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
67 Scopes: []string{"org:create_api_key", "user:profile", "user:inference"},
68 RedirectHost: "localhost",
69 RedirectPort: 53692,
70 RedirectPath: "/callback",
71 ExtraAuthArgs: map[string]string{
72 "code": "true",
73 },
74 TokenBodyJSON: true,
75 StateEqualsVerifier: true,
76 IncludeStateInTokenRequest: true,
77 }
78
79 // Anthropic manual / headless variant: redirects to Anthropic's
80 // copy-code page instead of a local loopback port, used when there
81 // is no browser on the machine running zut (e.g. inside a Docker
82 // container or over plain SSH).
83 AnthropicManualOAuth = OAuthProvider{
84 Name: "anthropic",
85 AuthURL: "https://claude.ai/oauth/authorize",
86 TokenURL: "https://console.anthropic.com/v1/oauth/token",
87 ClientID: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
88 Scopes: []string{"org:create_api_key", "user:profile", "user:inference"},
89 RedirectPath: "https://console.anthropic.com/oauth/code/callback",
90 ExtraAuthArgs: map[string]string{
91 "code": "true",
92 },
93 TokenBodyJSON: true,
94 StateEqualsVerifier: true,
95 IncludeStateInTokenRequest: true,
96 }
97
98 // OpenAI ChatGPT subscription: used by Codex CLI.
99 OpenAIOAuth = OAuthProvider{
100 Name: "openai",
101 AuthURL: "https://auth.openai.com/oauth/authorize",
102 TokenURL: "https://auth.openai.com/oauth/token",
103 ClientID: "app_EMoamEEZ73f0CkXaXp7hrann",
104 Scopes: []string{"openid", "profile", "email", "offline_access"},
105 RedirectHost: "localhost",
106 RedirectPort: 1455,
107 RedirectPath: "/auth/callback",
108 ExtraAuthArgs: map[string]string{
109 "id_token_add_organizations": "true",
110 "codex_cli_simplified_flow": "true",
111 // OpenAI's registered Codex OAuth flow requires this legacy
112 // originator value; it is not a zut product identifier.
113 "originator": "zot",
114 },
115 }
116
117 // Kimi Code subscription: used by the official Kimi Code CLI.
118 // Kimi uses OAuth 2 device-code flow instead of a loopback callback.
119 KimiOAuth = OAuthProvider{
120 Name: "kimi",
121 TokenURL: "https://auth.kimi.com/api/oauth/token",
122 ClientID: "17e5f671-d194-4dfb-9706-5516cb48c098",
123 }
124)
125
126// PKCE holds a verifier/challenge pair.
127type PKCE struct {
128 Verifier string
129 Challenge string
130}
131
132// NewPKCE generates a random PKCE pair using SHA-256.
133func NewPKCE() (PKCE, error) {
134 buf := make([]byte, 32)
135 if _, err := rand.Read(buf); err != nil {
136 return PKCE{}, err
137 }
138 verifier := base64.RawURLEncoding.EncodeToString(buf)
139 sum := sha256.Sum256([]byte(verifier))
140 challenge := base64.RawURLEncoding.EncodeToString(sum[:])
141 return PKCE{Verifier: verifier, Challenge: challenge}, nil
142}
143
144// RandomHex returns a random hex string, used as an OAuth state value.
145func RandomHex(n int) (string, error) {
146 buf := make([]byte, n)
147 if _, err := rand.Read(buf); err != nil {
148 return "", err
149 }
150 return hex.EncodeToString(buf), nil
151}
152
153// AuthorizeURL builds the browser URL for the initial authorization
154// request. The returned state value is what we expect to see on the
155// callback and what the caller should persist until that arrives.
156func (p OAuthProvider) AuthorizeURL(pkce PKCE) (authURL, state string, err error) {
157 if p.StateEqualsVerifier {
158 state = pkce.Verifier
159 } else {
160 state, err = RandomHex(16)
161 if err != nil {
162 return "", "", err
163 }
164 }
165 q := url.Values{}
166 q.Set("response_type", "code")
167 q.Set("client_id", p.ClientID)
168 q.Set("redirect_uri", p.RedirectURI())
169 q.Set("scope", strings.Join(p.Scopes, " "))
170 q.Set("code_challenge", pkce.Challenge)
171 q.Set("code_challenge_method", "S256")
172 q.Set("state", state)
173 for k, v := range p.ExtraAuthArgs {
174 q.Set(k, v)
175 }
176 return p.AuthURL + "?" + q.Encode(), state, nil
177}
178
179// Exchange swaps an authorization code for an OAuthToken.
180func (p OAuthProvider) Exchange(ctx context.Context, code, state string, pkce PKCE) (*OAuthToken, error) {
181 payload := map[string]string{
182 "grant_type": "authorization_code",
183 "client_id": p.ClientID,
184 "code": code,
185 "redirect_uri": p.RedirectURI(),
186 "code_verifier": pkce.Verifier,
187 }
188 if p.IncludeStateInTokenRequest {
189 payload["state"] = state
190 }
191 for k, v := range p.ExtraTokenArgs {
192 payload[k] = v
193 }
194 return p.doTokenRequest(ctx, payload)
195}
196
197// Refresh uses a refresh_token to obtain a new access token.
198func (p OAuthProvider) Refresh(ctx context.Context, refreshToken string) (*OAuthToken, error) {
199 payload := map[string]string{
200 "grant_type": "refresh_token",
201 "client_id": p.ClientID,
202 "refresh_token": refreshToken,
203 }
204 for k, v := range p.ExtraTokenArgs {
205 payload[k] = v
206 }
207 return p.doTokenRequest(ctx, payload)
208}
209
210// ExtractOpenAIAccountID parses the chatgpt_account_id claim from an
211// OpenAI id_token JWT. The claim sits under "https://api.openai.com/auth"
212// in the JWT payload. Returns "" if the token is malformed or lacks
213// the claim.
214func ExtractOpenAIAccountID(idToken string) string {
215 parts := strings.Split(idToken, ".")
216 if len(parts) < 2 {
217 return ""
218 }
219 payload, err := base64.RawURLEncoding.DecodeString(parts[1])
220 if err != nil {
221 // Some tokens use standard base64 with padding.
222 payload, err = base64.StdEncoding.DecodeString(parts[1])
223 if err != nil {
224 return ""
225 }
226 }
227 var body map[string]interface{}
228 if err := json.Unmarshal(payload, &body); err != nil {
229 return ""
230 }
231 authClaim, _ := body["https://api.openai.com/auth"].(map[string]interface{})
232 if authClaim == nil {
233 return ""
234 }
235 id, _ := authClaim["chatgpt_account_id"].(string)
236 return id
237}
238
239func (p OAuthProvider) doTokenRequest(ctx context.Context, payload map[string]string) (*OAuthToken, error) {
240 var (
241 req *http.Request
242 err error
243 contentType string
244 )
245 if p.TokenBodyJSON {
246 body, _ := json.Marshal(payload)
247 req, err = http.NewRequestWithContext(ctx, "POST", p.TokenURL, strings.NewReader(string(body)))
248 contentType = "application/json"
249 } else {
250 form := url.Values{}
251 for k, v := range payload {
252 form.Set(k, v)
253 }
254 req, err = http.NewRequestWithContext(ctx, "POST", p.TokenURL, strings.NewReader(form.Encode()))
255 contentType = "application/x-www-form-urlencoded"
256 }
257 if err != nil {
258 return nil, err
259 }
260 req.Header.Set("content-type", contentType)
261 req.Header.Set("accept", "application/json")
262
263 client := &http.Client{Timeout: 30 * time.Second}
264 resp, err := client.Do(req)
265 if err != nil {
266 return nil, fmt.Errorf("token request: %w", err)
267 }
268 defer resp.Body.Close()
269 respBody, _ := io.ReadAll(resp.Body)
270 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
271 return nil, fmt.Errorf("token http %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
272 }
273 var raw struct {
274 AccessToken string `json:"access_token"`
275 RefreshToken string `json:"refresh_token"`
276 TokenType string `json:"token_type"`
277 ExpiresIn int `json:"expires_in"`
278 Scope string `json:"scope"`
279 IDToken string `json:"id_token"`
280 }
281 if err := json.Unmarshal(respBody, &raw); err != nil {
282 return nil, fmt.Errorf("parse token response: %w: %s", err, string(respBody))
283 }
284 if raw.AccessToken == "" {
285 return nil, fmt.Errorf("empty access token in response: %s", string(respBody))
286 }
287 tok := &OAuthToken{
288 AccessToken: raw.AccessToken,
289 RefreshToken: raw.RefreshToken,
290 TokenType: raw.TokenType,
291 Scope: raw.Scope,
292 ClientID: p.ClientID,
293 }
294 if raw.ExpiresIn > 0 {
295 tok.Expiry = time.Now().Add(time.Duration(raw.ExpiresIn) * time.Second)
296 }
297 // Capture id_token & account id for providers that use OIDC (OpenAI).
298 // This lets the OpenAI Codex provider send chatgpt-account-id.
299 if raw.IDToken != "" {
300 tok.IDToken = raw.IDToken
301 tok.AccountID = ExtractOpenAIAccountID(raw.IDToken)
302 }
303 return tok, nil
304}