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