v0.27.1 131edba

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. 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}

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