v0.0.26 744685d

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

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