mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* fix(oauth): add missing api.connectors scopes for ChatGPT OAuth preset * fix(oauth): add missing authorize params for ChatGPT OAuth (id_token_add_organizations, codex_cli_simplified_flow, originator) * fix(oauth): use localhost + /auth/callback for ChatGPT redirect URI (matches Codex CLI registration) * fix(oauth): use fixed port 1455 + localhost/auth/callback for ChatGPT (matches opencode + Codex CLI client registration) * chore: gofmt chatgpt.go * refactor(oauth): use RedirectURIWithHost for ChatGPT callback; drop dead Port() RedirectURIWithHost and Port() were added but never called — chatgpt.go hardcoded "http://localhost:1455/auth/callback" as a literal, duplicating both the port and path. Use RedirectURIWithHost("localhost", "/auth/callback") so the redirect_uri derives its port from the listener (single source of truth via the new chatgptCallbackPort constant), and remove the unused Port() method. Also make the fixed-port bind failure actionable: ChatGPT's client registration requires exactly port 1455, so we can't fall back to a random port — but if 1455 is already bound (a prior Zero/Codex login still open) the error now says so instead of surfacing a raw OS bind error. Tests: RedirectURIWithHost host/path formatting, and that the listener captures the code on the /auth/callback path (not just /callback) — both were previously uncovered. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(oauth): restore api.connectors scopes for ChatGPT preset (review fix) The fixed-port commit accidentally reverted internal/oauth/presets.go back to only openid/profile/email/offline_access, dropping the api.connectors.read and api.connectors.invoke scopes this PR exists to add — so authorize URLs still omitted them and the authorize_hydra_invalid_request failure was not actually fixed (caught in review by anandh8x). Restore both connector scopes in the chatgpt preset and pin them with tests: - TestResolveConfigChatGPTPreset now asserts both connector scopes are present (not just allowed). - New TestChatGPTAuthorizeURLIncludesConnectorScopes inspects the generated authorize URL and asserts the scope param carries api.connectors.read and api.connectors.invoke, so a future revert is caught end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(oauth): read chatgpt_account_id from the nested OpenAI auth claim (P0) extractChatGPTAccountID read chatgpt_account_id as a TOP-LEVEL id_token claim, but auth.openai.com namespaces it under the "https://api.openai.com/auth" claim object (the Codex CLI reads only from there). Against a real token the top-level lookup returned "", so token.Account stayed empty → the codex provider omitted the required chatgpt-account-id header on every request → Cloudflare/backend 401. The login itself succeeded (bearer minted), so the failure was silent: the user authenticated fine, then every Codex API call 401'd. The unit tests passed only because the fixture put the claim at the top level, masking the bug. Read the nested path first (id_token[ns][chatgpt_account_id]), matching codex; keep a bare top-level claim as a forward-compat fallback only. Tests now build the fixture with the nested shape a real token uses, plus regressions: a top-level decoy must not shadow the nested value, and the top-level fallback still works when the namespace is absent. Found by an adversarial audit against the codex reference (same OAuth client). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
127 lines
4.2 KiB
Go
127 lines
4.2 KiB
Go
package oauth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// LoopbackListener is a single-use loopback HTTP server that captures an OAuth
|
|
// redirect (?code=&state=) on 127.0.0.1 only: bind an OS-assigned loopback port,
|
|
// verify the CSRF state, hand back the code, then close.
|
|
type LoopbackListener struct {
|
|
listener net.Listener
|
|
state string
|
|
result chan callbackResult
|
|
server *http.Server
|
|
}
|
|
|
|
type callbackResult struct {
|
|
code string
|
|
err error
|
|
}
|
|
|
|
// NewLoopbackListener binds 127.0.0.1:0 (loopback only, OS-assigned port) and
|
|
// begins serving. state is the CSRF value the callback must echo back. Call
|
|
// RedirectURI to build the redirect_uri, then Wait for the code. Always Close.
|
|
func NewLoopbackListener(state string) (*LoopbackListener, error) {
|
|
return NewLoopbackListenerOnPort(state, 0)
|
|
}
|
|
|
|
// NewLoopbackListenerOnPort is like NewLoopbackListener but binds a specific
|
|
// port (0 = OS-assigned). Used by ChatGPT OAuth which requires a fixed
|
|
// redirect_uri of http://localhost:1455/auth/callback.
|
|
func NewLoopbackListenerOnPort(state string, port int) (*LoopbackListener, error) {
|
|
if strings.TrimSpace(state) == "" {
|
|
return nil, errors.New("oauth: loopback listener requires a non-empty CSRF state")
|
|
}
|
|
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oauth: start loopback redirect listener: %w", err)
|
|
}
|
|
l := &LoopbackListener{
|
|
listener: ln,
|
|
state: state,
|
|
result: make(chan callbackResult, 1),
|
|
}
|
|
l.server = &http.Server{Handler: http.HandlerFunc(l.handle)}
|
|
go func() { _ = l.server.Serve(ln) }()
|
|
return l, nil
|
|
}
|
|
|
|
// RedirectURI returns the http://127.0.0.1:<port>/callback redirect URI.
|
|
func (l *LoopbackListener) RedirectURI() string {
|
|
return fmt.Sprintf("http://%s/callback", l.listener.Addr().String())
|
|
}
|
|
|
|
// RedirectURIWithHost returns a redirect URI using the given host (e.g.
|
|
// "localhost") and path (e.g. "/auth/callback"). The listener still binds
|
|
// 127.0.0.1, but the OAuth client may require "localhost" in the redirect_uri
|
|
// (OpenAI's ChatGPT client registration does). The listener accepts both
|
|
// /callback and the given path.
|
|
func (l *LoopbackListener) RedirectURIWithHost(host, path string) string {
|
|
_, port, _ := net.SplitHostPort(l.listener.Addr().String())
|
|
return fmt.Sprintf("http://%s:%s%s", host, port, path)
|
|
}
|
|
|
|
func (l *LoopbackListener) handle(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/callback" && r.URL.Path != "/auth/callback" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
code, err := parseCallback(r.URL.Query(), l.state)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
|
|
} else {
|
|
_, _ = io.WriteString(w, "Authorization complete. You may close this window.")
|
|
}
|
|
select {
|
|
case l.result <- callbackResult{code: code, err: err}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// Wait blocks until the callback arrives or ctx is done, returning the
|
|
// authorization code.
|
|
func (l *LoopbackListener) Wait(ctx context.Context) (string, error) {
|
|
select {
|
|
case res := <-l.result:
|
|
return res.code, res.err
|
|
case <-ctx.Done():
|
|
return "", fmt.Errorf("oauth: timed out waiting for authorization callback: %w", ctx.Err())
|
|
}
|
|
}
|
|
|
|
// Close shuts the listener down (bounded), idempotent.
|
|
func (l *LoopbackListener) Close() {
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
_ = l.server.Shutdown(shutdownCtx)
|
|
}
|
|
|
|
// parseCallback validates the redirect query and returns the authorization code,
|
|
// rejecting a mismatched state (CSRF) and surfacing provider errors.
|
|
func parseCallback(values url.Values, wantState string) (string, error) {
|
|
if got := values.Get("state"); got != wantState {
|
|
return "", ErrStateMismatch
|
|
}
|
|
if providerErr := strings.TrimSpace(values.Get("error")); providerErr != "" {
|
|
if desc := strings.TrimSpace(values.Get("error_description")); desc != "" {
|
|
return "", fmt.Errorf("oauth: authorization server returned error %q: %s", providerErr, desc)
|
|
}
|
|
return "", fmt.Errorf("oauth: authorization server returned error %q", providerErr)
|
|
}
|
|
code := strings.TrimSpace(values.Get("code"))
|
|
if code == "" {
|
|
return "", errors.New("oauth: callback missing authorization code")
|
|
}
|
|
return code, nil
|
|
}
|