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.
48func (p OAuthProvider) RedirectURI() string {
49 return fmt.Sprintf("http://%s:%d%s", p.RedirectHost, p.RedirectPort, p.RedirectPath)
50}
51
52// Built-in providers. Values mirror Anthropic's Claude Code CLI and
53// OpenAI's Codex CLI. Changing any of these fields will most likely
54// cause the flow to fail with redirect_uri_mismatch or invalid_client.
55var (
56 // Anthropic Claude Pro/Max: used by the Claude Code CLI.
57 AnthropicOAuth = OAuthProvider{
58 Name: "anthropic",
59 AuthURL: "https://claude.ai/oauth/authorize",
60 TokenURL: "https://platform.claude.com/v1/oauth/token",
61 ClientID: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
62 Scopes: []string{"org:create_api_key", "user:profile", "user:inference"},
63 RedirectHost: "localhost",
64 RedirectPort: 53692,
65 RedirectPath: "/callback",
66 ExtraAuthArgs: map[string]string{
67 "code": "true",
68 },
69 TokenBodyJSON: true,
70 StateEqualsVerifier: true,
71 IncludeStateInTokenRequest: true,
72 }
73
74 // OpenAI ChatGPT subscription: used by Codex CLI.
75 OpenAIOAuth = OAuthProvider{
76 Name: "openai",
77 AuthURL: "https://auth.openai.com/oauth/authorize",
78 TokenURL: "https://auth.openai.com/oauth/token",
79 ClientID: "app_EMoamEEZ73f0CkXaXp7hrann",
80 Scopes: []string{"openid", "profile", "email", "offline_access"},
81 RedirectHost: "localhost",
82 RedirectPort: 1455,
83 RedirectPath: "/auth/callback",
84 ExtraAuthArgs: map[string]string{
85 "id_token_add_organizations": "true",
86 "codex_cli_simplified_flow": "true",
87 "originator": "zot",
88 },
89 }
90)
91
92// PKCE holds a verifier/challenge pair.
93type PKCE struct {
94 Verifier string
95 Challenge string
96}
97
98// NewPKCE generates a random PKCE pair using SHA-256.
99func NewPKCE() (PKCE, error) {
100 buf := make([]byte, 32)
101 if _, err := rand.Read(buf); err != nil {
102 return PKCE{}, err
103 }
104 verifier := base64.RawURLEncoding.EncodeToString(buf)
105 sum := sha256.Sum256([]byte(verifier))
106 challenge := base64.RawURLEncoding.EncodeToString(sum[:])
107 return PKCE{Verifier: verifier, Challenge: challenge}, nil
108}
109
110// RandomHex returns a random hex string, used as an OAuth state value.
111func RandomHex(n int) (string, error) {
112 buf := make([]byte, n)
113 if _, err := rand.Read(buf); err != nil {
114 return "", err
115 }
116 return hex.EncodeToString(buf), nil
117}
118
119// AuthorizeURL builds the browser URL for the initial authorization
120// request. The returned state value is what we expect to see on the
121// callback and what the caller should persist until that arrives.
122func (p OAuthProvider) AuthorizeURL(pkce PKCE) (authURL, state string, err error) {
123 if p.StateEqualsVerifier {
124 state = pkce.Verifier
125 } else {
126 state, err = RandomHex(16)
127 if err != nil {
128 return "", "", err
129 }
130 }
131 q := url.Values{}
132 q.Set("response_type", "code")
133 q.Set("client_id", p.ClientID)
134 q.Set("redirect_uri", p.RedirectURI())
135 q.Set("scope", strings.Join(p.Scopes, " "))
136 q.Set("code_challenge", pkce.Challenge)
137 q.Set("code_challenge_method", "S256")
138 q.Set("state", state)
139 for k, v := range p.ExtraAuthArgs {
140 q.Set(k, v)
141 }
142 return p.AuthURL + "?" + q.Encode(), state, nil
143}
144
145// Exchange swaps an authorization code for an OAuthToken.
146func (p OAuthProvider) Exchange(ctx context.Context, code, state string, pkce PKCE) (*OAuthToken, error) {
147 payload := map[string]string{
148 "grant_type": "authorization_code",
149 "client_id": p.ClientID,
150 "code": code,
151 "redirect_uri": p.RedirectURI(),
152 "code_verifier": pkce.Verifier,
153 }
154 if p.IncludeStateInTokenRequest {
155 payload["state"] = state
156 }
157 for k, v := range p.ExtraTokenArgs {
158 payload[k] = v
159 }
160 return p.doTokenRequest(ctx, payload)
161}
162
163// Refresh uses a refresh_token to obtain a new access token.
164func (p OAuthProvider) Refresh(ctx context.Context, refreshToken string) (*OAuthToken, error) {
165 payload := map[string]string{
166 "grant_type": "refresh_token",
167 "client_id": p.ClientID,
168 "refresh_token": refreshToken,
169 }
170 for k, v := range p.ExtraTokenArgs {
171 payload[k] = v
172 }
173 return p.doTokenRequest(ctx, payload)
174}
175
176// ExtractOpenAIAccountID parses the chatgpt_account_id claim from an
177// OpenAI id_token JWT. The claim sits under "https://api.openai.com/auth"
178// in the JWT payload. Returns "" if the token is malformed or lacks
179// the claim.
180func ExtractOpenAIAccountID(idToken string) string {
181 parts := strings.Split(idToken, ".")
182 if len(parts) < 2 {
183 return ""
184 }
185 payload, err := base64.RawURLEncoding.DecodeString(parts[1])
186 if err != nil {
187 // Some tokens use standard base64 with padding.
188 payload, err = base64.StdEncoding.DecodeString(parts[1])
189 if err != nil {
190 return ""
191 }
192 }
193 var body map[string]interface{}
194 if err := json.Unmarshal(payload, &body); err != nil {
195 return ""
196 }
197 authClaim, _ := body["https://api.openai.com/auth"].(map[string]interface{})
198 if authClaim == nil {
199 return ""
200 }
201 id, _ := authClaim["chatgpt_account_id"].(string)
202 return id
203}
204
205func (p OAuthProvider) doTokenRequest(ctx context.Context, payload map[string]string) (*OAuthToken, error) {
206 var (
207 req *http.Request
208 err error
209 contentType string
210 )
211 if p.TokenBodyJSON {
212 body, _ := json.Marshal(payload)
213 req, err = http.NewRequestWithContext(ctx, "POST", p.TokenURL, strings.NewReader(string(body)))
214 contentType = "application/json"
215 } else {
216 form := url.Values{}
217 for k, v := range payload {
218 form.Set(k, v)
219 }
220 req, err = http.NewRequestWithContext(ctx, "POST", p.TokenURL, strings.NewReader(form.Encode()))
221 contentType = "application/x-www-form-urlencoded"
222 }
223 if err != nil {
224 return nil, err
225 }
226 req.Header.Set("content-type", contentType)
227 req.Header.Set("accept", "application/json")
228
229 client := &http.Client{Timeout: 30 * time.Second}
230 resp, err := client.Do(req)
231 if err != nil {
232 return nil, fmt.Errorf("token request: %w", err)
233 }
234 defer resp.Body.Close()
235 respBody, _ := io.ReadAll(resp.Body)
236 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
237 return nil, fmt.Errorf("token http %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
238 }
239 var raw struct {
240 AccessToken string `json:"access_token"`
241 RefreshToken string `json:"refresh_token"`
242 TokenType string `json:"token_type"`
243 ExpiresIn int `json:"expires_in"`
244 Scope string `json:"scope"`
245 IDToken string `json:"id_token"`
246 }
247 if err := json.Unmarshal(respBody, &raw); err != nil {
248 return nil, fmt.Errorf("parse token response: %w: %s", err, string(respBody))
249 }
250 if raw.AccessToken == "" {
251 return nil, fmt.Errorf("empty access token in response: %s", string(respBody))
252 }
253 tok := &OAuthToken{
254 AccessToken: raw.AccessToken,
255 RefreshToken: raw.RefreshToken,
256 TokenType: raw.TokenType,
257 Scope: raw.Scope,
258 ClientID: p.ClientID,
259 }
260 if raw.ExpiresIn > 0 {
261 tok.Expiry = time.Now().Add(time.Duration(raw.ExpiresIn) * time.Second)
262 }
263 // Capture id_token & account id for providers that use OIDC (OpenAI).
264 // This lets the OpenAI Codex provider send chatgpt-account-id.
265 if raw.IDToken != "" {
266 tok.IDToken = raw.IDToken
267 tok.AccountID = ExtractOpenAIAccountID(raw.IDToken)
268 }
269 return tok, nil
270}