mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* feat(oauth): add Hugging Face + ChatGPT Codex as first-class built-in OAuth providers
Adds two new built-in OAuth presets that round out the existing OpenRouter and
xAI surfaces, so the TUI `/provider` OAuth chooser and `zero auth login` work
out of the box for these providers.
Hugging Face
- Preset scopes, endpoints, and OIDC issuer are pre-filled; the operator
supplies a "public" OAuth client_id (no secret) via
`ZERO_OAUTH_HUGGINGFACE_CLIENT_ID` after registering an app at
huggingface.co/settings/applications/new.
- Default flow is RFC 8628 device code, so headless/SSH is the first-class
path (matches the existing xAI posture).
- The bearer works against the OpenAI-compatible Inference Providers router
at `https://router.huggingface.co/v1` for hundreds of OSS models.
ChatGPT (Codex backend)
- Uses the publicly-shipped Codex CLI client identity
(`app_EMoamEEZ73f0CkXaXp7hrann`), opt-in via `ZERO_OAUTH_ALLOW_PRESETS=1`,
overridable via `ZERO_OAUTH_CHATGPT_*`.
- The bespoke `provideroauth.ChatGPTLogin` extracts the `chatgpt_account_id`
claim from the OIDC ID token (added as `IDToken` on the `oauth.Token`
struct) and stores it on `Token.Account` so the Codex provider can inject
it as a header on every request.
- A new `internal/providers/openai/codex.go` wraps the openai provider with
the Codex-flavored request shape: `originator: codex_cli_rs`,
`chatgpt-account-id: <claim>`, and a branded User-Agent. The factory
branches off the catalog id (not the provider kind) so other
openai-compatible providers keep the standard openai path unchanged.
- The openai provider gained a `SetRequestExtra` callback so the Codex
wrapper can inject per-request headers without coupling the openai
provider to Codex.
CLI / TUI
- `zero auth chatgpt` is a one-shot login command (analog to
`zero auth openrouter`); the TUI's OAuth chooser now lists both new
providers automatically (the list is built from
`providercatalog.OAuthProviders()`).
- The provider-wizard visible-row cap was bumped from 8 to 10 so the
common providers (OpenAI, Anthropic, Google, Ollama, OpenRouter, the
new Hugging Face, the new ChatGPT, Groq, …) all fit without scrolling
the test fixtures.
Tests
- `internal/oauth/presets_test.go`: presets resolve correctly with the
expected endpoints, scopes, and flows; env overrides win; Hugging Face
preset stays inert without a client_id (the safety posture the existing
xAI tests assert).
- `internal/providercatalog/oauth_test.go` + `catalog_test.go`: catalog
now exposes 4 OAuth providers (`openrouter`, `huggingface`, `chatgpt`,
`xai`); the `chatgpt` descriptor is `OAuth=true` with
`OAuthDeviceFlow=false` (Codex is loopback-only) and `RequiresAuth=true`
via an inline IIFE that sets it after the openAICompat helper.
- `internal/provideroauth/chatgpt_test.go`: ID-token claim extraction
round-trips (happy path, missing claim, missing token, tampered JWS,
non-string claim); the full login flow runs against a stub auth server
and verifies the preset's client_id reaches the token endpoint.
- `internal/providers/openai/codex_test.go`: every request gets the three
Codex headers; the 401-refresh retry path keeps them on the second
attempt; configured baseURL wins; default originator is `codex_cli_rs`;
branded User-Agent wins over the openai default; AccountResolver is
consulted when AccountID is empty; the header is omitted when the
resolver says no.
Out of scope (separate PRs): multi-account / profiles, generic
template-driven OAuth wizard, MCP / provider OAuth store unification,
subscription-proxy autoconfig.
* fix(ci): gofmt chatgpt_test.go and provider_wizard.go
The ubuntu smoke job's "Check formatting" step lists these two files as
unformatted (struct field alignment). The local gofmt run on Windows
also lists ~170 other files, but those are CRLF-vs-LF noise from
checkout and not real gofmt issues on Linux; the CI only flags the
two struct-alignment changes below.
- internal/provideroauth/chatgpt_test.go: align chatgptTestServer fields
- internal/tui/provider_wizard.go: align ChatGPTOptions literal fields
* fix(codex): hit the /responses path and resolve account id per request
Addresses the two findings in anandh8x's CHANGES_REQUESTED review on #245.
The Codex provider previously routed the chat-completions body to
{baseURL}/chat/completions, which is not the path the Codex backend serves
(chatgpt.com/backend-api/codex/responses, the Responses API). Add an
Endpoint field to openai.Options so a flavor can target a sibling path on
the same host; the Codex constructor sets it to {baseURL}/responses and
falls back to the openai transport unchanged for everything else.
The factory was also passing the OAuth token's Account field as a static
AccountID at construction time. A refresh that rotates the token (and
its account claim) would leave a stale value on the wire. Drop the
static AccountID from the factory wiring; the per-request
AccountResolver (which reads the live store) is now the sole source, so
a refresh takes effect on the next outgoing request.
Tests:
- codex_test.go: handler mounted on /responses; expected path updated;
TestCodexProviderResolverIsConsultedOnEveryRequest pins the per-request
resolver behavior (a refresh-style sequence of calls picks up the new
account id on the second attempt).
- factory_test.go: expected request path updated to /responses.
* Addresses the Codex path shape on /responses.
The previous fix (2381197) corrected the URL and the per-request account
resolver, but the Codex provider still spoke chat-completions: it sent
{model, messages, tools, stream, stream_options, max_completion_tokens}
to /responses and parsed data: [DONE]-terminated SSE frames
(choices[].delta). The Codex backend at chatgpt.com/backend-api/codex
serves the Responses API — input items, function_call /
function_call_output, response.output_text.delta /
response.function_call_arguments.delta events — and rejects a
chat-completions body.
The Codex provider now has its own request/stream path that matches the
Responses schema:
- internal/providers/openai/codex_responses.go (new) holds the wire
types (responsesRequest, inputItem, contentItem, responsesTool,
responsesEvent, itemPayload, responsePayload, usagePayload), the
request builder (system → message/role=system, user/assistant →
message with text or content parts, prior tool calls → function_call,
tool results → function_call_output, tools → responsesTool), the
Responses SSE dispatcher, the tool-call accumulator
(response.output_item.added registers the call id + name, the
argument deltas accumulate, response.output_item.done finalizes),
and the terminal-event handler (response.completed emits usage +
done, response.failed surfaces the error after the usage chunk so
token accounting is preserved).
- internal/providers/openai/codex.go: StreamCompletion now marshals a
Responses request and dispatches via the new streamResponses
function. The wrapped openai Provider is still constructed for its
validated endpoint / auth / retry / idle-timeout config and its
SetRequestExtra callback, but the openai chat-completions stream
parser is no longer used for Codex. injectCodexHeaders still runs
on every attempt (including the 401-refresh retry) so the Codex
headers stay attached to the rotated bearer.
The shared providerio plumbing (auth + 401-retry, SSE scanner with
the idle watchdog, error humanization, secret redaction) is reused
verbatim so the OAuth 401-refresh and bearer-rotation paths stay
identical to the rest of the openai-compatible surface. The
stream-end fallback surfaces a StreamEventDone with
FinishReasonLength when the Codex backend closes the stream without
a response.completed (e.g. an internal truncation), so the runtime
can react instead of hanging on a phantom completion.
Tests:
- internal/providers/openai/codex_test.go:
- newCodexTestServer now returns a minimal Responses sequence
(response.created + response.completed); newCodexResponsesServer
takes a custom payload sequence for richer cases.
- The two tests with inline handlers (TestCodexProviderResolverIs-
ConsultedOnEveryRequest, TestCodexProviderRetriesHeadersAfter401)
use a response.completed payload on the second attempt instead of
data: [DONE].
- TestCodexProviderSendsResponsesRequestShape replaces the old
TestCodexProviderSendsRequestBody and pins the Responses shape:
the body MUST carry an `input` array of message / function_call
/ function_call_output items and `tools` of function type, and
MUST NOT carry the chat-completions `messages` field.
- TestCodexProviderParsesResponsesTextDeltas: response.output_text.
delta events surface as StreamEventText in order, the
response.completed usage chunk surfaces as StreamEventUsage
(with input_tokens, output_tokens, cached_input_tokens, and
reasoning_tokens from the optional details blocks), and
StreamEventDone follows.
- TestCodexProviderParsesResponsesToolCalls:
response.output_item.added emits StreamEventToolCallStart with
the right name, response.function_call_arguments.delta events
emit StreamEventToolCallDelta in order, response.output_item.done
emits StreamEventToolCallEnd.
- TestCodexProviderSendsAssistantToolCallsAsInputItems: a runtime
message with content + tool_calls serializes as BOTH the
assistant message and a function_call item; a tool result
serializes as a function_call_output. The expected count is 5
items (user + assistant text + function_call + function_call_
output + user) — earlier this test had 4, which would have
missed the assistant message before the function_call.
- TestCodexProviderEmitsErrorOnResponseErrorEvent: a response.error
event surfaces as a single StreamEventError with the server's
message and stops the scan.
- TestCodexProviderEmitsErrorOnMalformedStream: a payload that
parses as JSON but lacks the `type` field is reported as a
stream error rather than silently dropped.
- TestCodexProviderEmitsLengthFinishWhenStreamEndsWithout-
Completion: a stream that closes without a response.completed
surfaces a StreamEventDone with FinishReasonLength.
Verification:
- `go build ./...` clean (with the user-owned untracked files moved
aside per the project's working-tree convention).
- `go vet ./internal/providers/...` clean.
- `go test ./internal/providers/... ./internal/providercatalog/...
./internal/provideroauth/... ./internal/oauth/...` PASS.
- `gofmt -l` on the three changed files clean.
103 lines
4.7 KiB
Go
103 lines
4.7 KiB
Go
// Package oauth is zero's reusable OAuth 2.0 engine. It generalizes the
|
|
// authorization-code + PKCE flow that internal/mcp already uses for MCP servers
|
|
// into a transport/identity-agnostic core, and adds the pieces provider login
|
|
// needs: a device-authorization (RFC 8628) grant, a provider registry, a
|
|
// namespaced + refreshing token store, and a proactive refresh scheduler.
|
|
//
|
|
// Security invariants (enforced throughout, never relaxed):
|
|
// - PKCE S256 is mandatory on every authorization-code flow; "plain" is refused.
|
|
// - A per-flow CSRF state is generated and verified on the callback.
|
|
// - The loopback callback server binds 127.0.0.1 only, on an OS-assigned port,
|
|
// serves a single request, then closes.
|
|
// - The token endpoint must be https (loopback exempt) before any credential is
|
|
// sent — see validateTokenEndpoint.
|
|
// - Tokens, codes, and verifiers are never logged; error bodies are redacted.
|
|
// - Token files are 0600, base-confined, and file-locked.
|
|
//
|
|
// This package holds NO vendor secrets: provider client IDs and endpoints come
|
|
// from config/env by default, so no third-party OAuth client identity is used
|
|
// unless the operator opts in. A small set of built-in presets (e.g. xAI's public
|
|
// client) exists for convenience but is OFF by default and only consulted when
|
|
// ZERO_OAUTH_ALLOW_PRESETS is set; env always overrides a preset.
|
|
package oauth
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Token holds the credentials issued by an authorization server. The token
|
|
// fields are sensitive: callers must never log them, and the store persists them
|
|
// 0600. It mirrors the on-disk shape internal/mcp uses so the two stay
|
|
// format-compatible.
|
|
type Token struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token,omitempty"`
|
|
TokenType string `json:"token_type,omitempty"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
|
// Account is an optional non-secret identifier (email / account id) shown in
|
|
// status output; never a credential.
|
|
Account string `json:"account,omitempty"`
|
|
// IDToken is the OIDC ID token returned alongside the access token (when the
|
|
// `openid` scope was requested). It is a JWS and may carry claims (such as
|
|
// `chatgpt_account_id`) that the request path needs as headers. Treated as
|
|
// sensitive like the access token: never logged, persisted 0600.
|
|
IDToken string `json:"id_token,omitempty"`
|
|
}
|
|
|
|
// Expired reports whether the token has an expiry that is at or before now.
|
|
// A zero ExpiresAt means "no known expiry" and is treated as not expired.
|
|
func (t Token) Expired(now time.Time) bool {
|
|
return !t.ExpiresAt.IsZero() && !t.ExpiresAt.After(now)
|
|
}
|
|
|
|
// NeedsRefresh reports whether the token is expired or falls within buffer of
|
|
// expiry (so a proactive/on-demand refresh should run). A token with no expiry
|
|
// never needs a refresh on a timer.
|
|
func (t Token) NeedsRefresh(now time.Time, buffer time.Duration) bool {
|
|
if t.ExpiresAt.IsZero() {
|
|
return false
|
|
}
|
|
return !t.ExpiresAt.After(now.Add(buffer))
|
|
}
|
|
|
|
// Config describes how to talk to one authorization server for a flow.
|
|
// Endpoints may be discovered (RFC 8414) and overridden here.
|
|
type Config struct {
|
|
ClientID string
|
|
ClientSecret string
|
|
Scopes []string
|
|
|
|
AuthorizationEndpoint string
|
|
TokenEndpoint string
|
|
DeviceAuthorizationEndpoint string
|
|
RegistrationEndpoint string
|
|
// IssuerURL is the base for metadata discovery when endpoints are not set.
|
|
IssuerURL string
|
|
|
|
// ExtraAuthParams are appended to the authorization URL (e.g. login_hint).
|
|
ExtraAuthParams map[string]string
|
|
}
|
|
|
|
// Errors returned by the engine. Callers can match these with errors.Is.
|
|
var (
|
|
// ErrPKCEDowngrade is returned if a flow is asked to use anything but S256.
|
|
ErrPKCEDowngrade = errors.New("oauth: PKCE S256 is mandatory; plain is refused")
|
|
// ErrStateMismatch is returned when the callback state does not match (CSRF).
|
|
ErrStateMismatch = errors.New("oauth: callback state mismatch; possible CSRF, login aborted")
|
|
// ErrInsecureTokenEndpoint is returned when a credential would be sent over a
|
|
// non-https, non-loopback endpoint.
|
|
ErrInsecureTokenEndpoint = errors.New("oauth: refusing to send credential to a non-https token endpoint")
|
|
// ErrNoRefreshToken is returned when a refresh is attempted without one.
|
|
ErrNoRefreshToken = errors.New("oauth: no refresh token available")
|
|
// ErrAuthorizationPending is the RFC 8628 "keep polling" signal.
|
|
ErrAuthorizationPending = errors.New("oauth: authorization pending")
|
|
// ErrSlowDown is the RFC 8628 "increase the poll interval" signal.
|
|
ErrSlowDown = errors.New("oauth: slow down")
|
|
)
|
|
|
|
// trimmed is a tiny helper used across the package.
|
|
func trimmed(s string) string { return strings.TrimSpace(s) }
|