Files
zero/internal/oauth/loopback.go
Vasanth TandClaude Opus 4.8 72a4229c2a fix(oauth): make ChatGPT (Codex) login work end-to-end (#264)
* 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]>
2026-06-20 23:00:01 +05:30

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
}