v0.1.88 14ffdb6

oauth.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close