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. zot 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 zot (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 "originator": "zot",
112 },
113 }
114
115 // Kimi Code subscription: used by the official Kimi Code CLI.
116 // Kimi uses OAuth 2 device-code flow instead of a loopback callback.
117 KimiOAuth = OAuthProvider{
118 Name: "kimi",
119 TokenURL: "https://auth.kimi.com/api/oauth/token",
120 ClientID: "17e5f671-d194-4dfb-9706-5516cb48c098",
121 }
122)
123
124// PKCE holds a verifier/challenge pair.
125type PKCE struct {
126 Verifier string
127 Challenge string
128}
129
130// NewPKCE generates a random PKCE pair using SHA-256.
131func NewPKCE() (PKCE, error) {
132 buf := make([]byte, 32)
133 if _, err := rand.Read(buf); err != nil {
134 return PKCE{}, err
135 }
136 verifier := base64.RawURLEncoding.EncodeToString(buf)
137 sum := sha256.Sum256([]byte(verifier))
138 challenge := base64.RawURLEncoding.EncodeToString(sum[:])
139 return PKCE{Verifier: verifier, Challenge: challenge}, nil
140}
141
142// RandomHex returns a random hex string, used as an OAuth state value.
143func RandomHex(n int) (string, error) {
144 buf := make([]byte, n)
145 if _, err := rand.Read(buf); err != nil {
146 return "", err
147 }
148 return hex.EncodeToString(buf), nil
149}
150
151// AuthorizeURL builds the browser URL for the initial authorization
152// request. The returned state value is what we expect to see on the
153// callback and what the caller should persist until that arrives.
154func (p OAuthProvider) AuthorizeURL(pkce PKCE) (authURL, state string, err error) {
155 if p.StateEqualsVerifier {
156 state = pkce.Verifier
157 } else {
158 state, err = RandomHex(16)
159 if err != nil {
160 return "", "", err
161 }
162 }
163 q := url.Values{}
164 q.Set("response_type", "code")
165 q.Set("client_id", p.ClientID)
166 q.Set("redirect_uri", p.RedirectURI())
167 q.Set("scope", strings.Join(p.Scopes, " "))
168 q.Set("code_challenge", pkce.Challenge)
169 q.Set("code_challenge_method", "S256")
170 q.Set("state", state)
171 for k, v := range p.ExtraAuthArgs {
172 q.Set(k, v)
173 }
174 return p.AuthURL + "?" + q.Encode(), state, nil
175}
176
177// Exchange swaps an authorization code for an OAuthToken.
178func (p OAuthProvider) Exchange(ctx context.Context, code, state string, pkce PKCE) (*OAuthToken, error) {
179 payload := map[string]string{
180 "grant_type": "authorization_code",
181 "client_id": p.ClientID,
182 "code": code,
183 "redirect_uri": p.RedirectURI(),
184 "code_verifier": pkce.Verifier,
185 }
186 if p.IncludeStateInTokenRequest {
187 payload["state"] = state
188 }
189 for k, v := range p.ExtraTokenArgs {
190 payload[k] = v
191 }
192 return p.doTokenRequest(ctx, payload)
193}
194
195// Refresh uses a refresh_token to obtain a new access token.
196func (p OAuthProvider) Refresh(ctx context.Context, refreshToken string) (*OAuthToken, error) {
197 payload := map[string]string{
198 "grant_type": "refresh_token",
199 "client_id": p.ClientID,
200 "refresh_token": refreshToken,
201 }
202 for k, v := range p.ExtraTokenArgs {
203 payload[k] = v
204 }
205 return p.doTokenRequest(ctx, payload)
206}
207
208// ExtractOpenAIAccountID parses the chatgpt_account_id claim from an
209// OpenAI id_token JWT. The claim sits under "https://api.openai.com/auth"
210// in the JWT payload. Returns "" if the token is malformed or lacks
211// the claim.
212func ExtractOpenAIAccountID(idToken string) string {
213 parts := strings.Split(idToken, ".")
214 if len(parts) < 2 {
215 return ""
216 }
217 payload, err := base64.RawURLEncoding.DecodeString(parts[1])
218 if err != nil {
219 // Some tokens use standard base64 with padding.
220 payload, err = base64.StdEncoding.DecodeString(parts[1])
221 if err != nil {
222 return ""
223 }
224 }
225 var body map[string]interface{}
226 if err := json.Unmarshal(payload, &body); err != nil {
227 return ""
228 }
229 authClaim, _ := body["https://api.openai.com/auth"].(map[string]interface{})
230 if authClaim == nil {
231 return ""
232 }
233 id, _ := authClaim["chatgpt_account_id"].(string)
234 return id
235}
236
237func (p OAuthProvider) doTokenRequest(ctx context.Context, payload map[string]string) (*OAuthToken, error) {
238 var (
239 req *http.Request
240 err error
241 contentType string
242 )
243 if p.TokenBodyJSON {
244 body, _ := json.Marshal(payload)
245 req, err = http.NewRequestWithContext(ctx, "POST", p.TokenURL, strings.NewReader(string(body)))
246 contentType = "application/json"
247 } else {
248 form := url.Values{}
249 for k, v := range payload {
250 form.Set(k, v)
251 }
252 req, err = http.NewRequestWithContext(ctx, "POST", p.TokenURL, strings.NewReader(form.Encode()))
253 contentType = "application/x-www-form-urlencoded"
254 }
255 if err != nil {
256 return nil, err
257 }
258 req.Header.Set("content-type", contentType)
259 req.Header.Set("accept", "application/json")
260
261 client := &http.Client{Timeout: 30 * time.Second}
262 resp, err := client.Do(req)
263 if err != nil {
264 return nil, fmt.Errorf("token request: %w", err)
265 }
266 defer resp.Body.Close()
267 respBody, _ := io.ReadAll(resp.Body)
268 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
269 return nil, fmt.Errorf("token http %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
270 }
271 var raw struct {
272 AccessToken string `json:"access_token"`
273 RefreshToken string `json:"refresh_token"`
274 TokenType string `json:"token_type"`
275 ExpiresIn int `json:"expires_in"`
276 Scope string `json:"scope"`
277 IDToken string `json:"id_token"`
278 }
279 if err := json.Unmarshal(respBody, &raw); err != nil {
280 return nil, fmt.Errorf("parse token response: %w: %s", err, string(respBody))
281 }
282 if raw.AccessToken == "" {
283 return nil, fmt.Errorf("empty access token in response: %s", string(respBody))
284 }
285 tok := &OAuthToken{
286 AccessToken: raw.AccessToken,
287 RefreshToken: raw.RefreshToken,
288 TokenType: raw.TokenType,
289 Scope: raw.Scope,
290 ClientID: p.ClientID,
291 }
292 if raw.ExpiresIn > 0 {
293 tok.Expiry = time.Now().Add(time.Duration(raw.ExpiresIn) * time.Second)
294 }
295 // Capture id_token & account id for providers that use OIDC (OpenAI).
296 // This lets the OpenAI Codex provider send chatgpt-account-id.
297 if raw.IDToken != "" {
298 tok.IDToken = raw.IDToken
299 tok.AccountID = ExtractOpenAIAccountID(raw.IDToken)
300 }
301 return tok, nil
302}