v0.1.29 48d22c4

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

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