From deb4ce4a1a76869771bfcdd758455c49815f0d13 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 13 Jun 2026 11:50:00 -0600 Subject: auth: clean-break named [auth.] sessions + extra_headers Replace provider-level api_key/api_key_env_var with named auth sessions: providers now reference `auth = ""` and credentials live under `[auth.]`. Adds the libpanto auth type surface (AuthConfig, TokenSet, ResolvedCredential), a Header type and provider `extra_headers`, and rewires the CLI config loader (parse [auth.], require auth=, providers always survive, eager api_key resolution). Updates the shipped default config. --- libpanto/src/auth.zig | 180 ++++++++++++++++++++++++++++++++++++++++++++++++ libpanto/src/config.zig | 18 +++++ libpanto/src/public.zig | 17 +++++ 3 files changed, 215 insertions(+) create mode 100644 libpanto/src/auth.zig (limited to 'libpanto/src') diff --git a/libpanto/src/auth.zig b/libpanto/src/auth.zig new file mode 100644 index 0000000..012e495 --- /dev/null +++ b/libpanto/src/auth.zig @@ -0,0 +1,180 @@ +//! Provider authentication: named auth sessions resolved into request-ready +//! credentials before a provider stream opens. +//! +//! A provider names the auth session it uses (`auth = ""`); core +//! resolves that session into a `ResolvedCredential` (a bearer/api-key value, +//! an optional dynamic `base_url`, and optional auth-derived request headers). +//! This covers three families with one configuration shape: +//! +//! - `api_key` — static key from a literal or an environment variable. +//! - `oauth_device` — OAuth 2.0 device-authorization flow. Two dialects: +//! * `token` — standard device flow (GitHub Copilot). The poll endpoint +//! returns the OAuth token response directly. +//! * `codex` — OpenAI Codex device flow. The poll endpoint returns an +//! authorization code plus a server-generated PKCE verifier; core +//! exchanges those at `token_url` for `{access,refresh,id}_token`. +//! +//! This module owns the *mechanism* (config types, the persisted token shape, +//! and — added incrementally — the HTTP flows and refresh lifecycle). It is +//! UI- and filesystem-policy-agnostic: token storage takes a directory path, +//! and interactive device-code prompts are delivered through a caller-supplied +//! `Presenter`. That keeps libpanto reusable while the embedder owns where +//! `$PANTO_HOME` is and how a device code is shown to the user. + +const std = @import("std"); +const config_mod = @import("config.zig"); + +/// A single request header. Re-exported from `config.zig` so callers can +/// build auth-derived headers and provider `extra_headers` from one type. +pub const Header = config_mod.Header; + +// =========================================================================== +// Auth configuration (parsed from `[auth.]`) +// =========================================================================== + +pub const AuthType = enum { api_key, oauth_device }; + +/// Device-flow completion shape. See the module doc. +pub const DeviceDialect = enum { token, codex }; + +/// Wire encoding for the `token` dialect's device-code / poll request bodies. +pub const TokenRequestFormat = enum { form, json }; + +/// Static API-key auth. `key` (a literal) wins over `key_env_var`. +pub const ApiKeyAuth = struct { + key: ?[]const u8 = null, + key_env_var: ?[]const u8 = null, +}; + +/// A secondary token exchange run after the durable OAuth token is obtained +/// (GitHub Copilot's `/copilot_internal/v2/token`). The exchange result — +/// not the OAuth token — becomes the request credential, and may also +/// override the provider `base_url`. +pub const ExchangeConfig = struct { + method: []const u8 = "GET", + url: []const u8, + /// Which stored token to send as the exchange request's bearer. + bearer: BearerSource = .oauth_access_token, + /// Dotted JSON path to the credential in the exchange response. + token_json_path: []const u8 = "token", + /// Dotted JSON path to the credential's unix-seconds expiry, if any. + expires_at_json_path: ?[]const u8 = null, + /// Dotted JSON path to a dynamic `base_url` in the exchange response. + base_url_json_path: ?[]const u8 = null, + /// Headers sent with the exchange request (e.g. Copilot editor identity). + headers: []const Header = &.{}, + + pub const BearerSource = enum { oauth_access_token }; +}; + +/// OAuth 2.0 device-authorization auth. +pub const OAuthDeviceAuth = struct { + dialect: DeviceDialect = .token, + client_id: []const u8, + /// Endpoint that issues the device/user code. + device_code_url: []const u8, + /// `token` dialect: poll-for-token and refresh endpoint. + /// `codex` dialect: authorization-code exchange and refresh endpoint. + token_url: []const u8, + /// `codex` dialect: endpoint polled for the authorization code. + device_poll_url: ?[]const u8 = null, + /// Browser URL shown to the user (codex). For the `token` dialect the + /// verification URI comes from the device-code response. + verification_url: ?[]const u8 = null, + scope: ?[]const u8 = null, + /// Request encoding for the `token` dialect device-code / poll bodies. + token_request_format: TokenRequestFormat = .form, + /// `codex` dialect: redirect URI sent in the code exchange. + redirect_uri: ?[]const u8 = null, + /// `codex` dialect: JWT claim (on the id_token) holding the account id, + /// e.g. `https://api.openai.com/auth`. When set, the resolver extracts an + /// account id and emits it as a header (see provider wiring). + account_id_jwt_claim: ?[]const u8 = null, + /// Headers sent with the device-code / poll / token requests. + headers: []const Header = &.{}, + /// Optional post-login token exchange (Copilot). + exchange: ?ExchangeConfig = null, +}; + +/// One named auth session's configuration. The union tag mirrors the TOML +/// `type` field. +pub const AuthConfig = union(AuthType) { + api_key: ApiKeyAuth, + oauth_device: OAuthDeviceAuth, +}; + +// =========================================================================== +// Persisted token state (`$PANTO_HOME/auth/.json`) +// =========================================================================== + +/// Durable auth state for one session. Only the fields relevant to the auth +/// type are populated. Treat the on-disk file like a password. +pub const TokenSet = struct { + /// `"api_key"` | `"oauth_device"` — records which family wrote the file. + type: []const u8 = "oauth_device", + /// Durable OAuth access token (the `ghu_...` for Copilot; the JWT access + /// token for Codex). + access_token: ?[]const u8 = null, + refresh_token: ?[]const u8 = null, + id_token: ?[]const u8 = null, + /// Unix-seconds expiry of `access_token` (when known; OAuth `expires_in` + /// or a JWT `exp`). Null for tokens with no intrinsic expiry (Copilot's + /// durable `ghu_` token). + expires_at: ?i64 = null, + /// Account id derived from the id_token (Codex `chatgpt-account-id`). + account_id: ?[]const u8 = null, + /// Result of the secondary exchange (Copilot short-lived API token). + exchange: ?ExchangeToken = null, + + pub const ExchangeToken = struct { + token: []const u8, + /// Unix-seconds expiry of the exchanged token. + expires_at: ?i64 = null, + /// Dynamic base_url returned by the exchange, if any. + base_url: ?[]const u8 = null, + }; +}; + +// =========================================================================== +// Resolved credential (handed to the provider for one turn) +// =========================================================================== + +/// The request-ready output of resolving an auth session: the secret to place +/// in the provider's auth header, plus any dynamic base_url and auth-derived +/// headers. The embedder injects these into the active `ProviderConfig`. +pub const ResolvedCredential = struct { + /// Value for the provider auth header (the bearer / x-api-key value). + api_key: []const u8, + /// Overrides the provider's configured `base_url` when present (e.g. + /// Copilot's `endpoints.api`). + base_url_override: ?[]const u8 = null, + /// Auth-derived headers (e.g. `chatgpt-account-id`) to merge onto the + /// provider's request. + extra_headers: []const Header = &.{}, +}; + +const t = std.testing; + +test "AuthConfig: api_key variant" { + const a: AuthConfig = .{ .api_key = .{ .key_env_var = "OPENAI_API_KEY" } }; + try t.expectEqual(AuthType.api_key, @as(AuthType, a)); + try t.expectEqualStrings("OPENAI_API_KEY", a.api_key.key_env_var.?); +} + +test "AuthConfig: oauth_device defaults" { + const a: AuthConfig = .{ .oauth_device = .{ + .client_id = "Iv1.x", + .device_code_url = "https://github.com/login/device/code", + .token_url = "https://github.com/login/oauth/access_token", + } }; + try t.expectEqual(DeviceDialect.token, a.oauth_device.dialect); + try t.expectEqual(TokenRequestFormat.form, a.oauth_device.token_request_format); + try t.expect(a.oauth_device.exchange == null); +} + +test "TokenSet: defaults" { + const ts: TokenSet = .{}; + try t.expectEqualStrings("oauth_device", ts.type); + try t.expect(ts.access_token == null); + try t.expect(ts.exchange == null); +} diff --git a/libpanto/src/config.zig b/libpanto/src/config.zig index 7e1e8e1..09aaec8 100644 --- a/libpanto/src/config.zig +++ b/libpanto/src/config.zig @@ -26,6 +26,16 @@ pub const APIStyle = enum { anthropic_messages, }; +/// A single HTTP request header (name/value). Used for provider +/// `extra_headers` — caller-supplied headers merged onto a provider's +/// built-in request headers (e.g. GitHub Copilot's editor-identity headers, +/// or auth-derived headers from an OAuth exchange). Borrowed slices; valid +/// as long as the owning config is. +pub const Header = struct { + name: []const u8, + value: []const u8, +}; + /// Reasoning intensity hint sent to providers that support it. /// /// `.default` omits the field entirely so the provider's own default applies. @@ -70,6 +80,11 @@ pub const OpenAIChatConfig = struct { model: []const u8, reasoning: ReasoningEffort = .default, max_tokens: u32 = 64_000, + /// Caller-supplied request headers merged onto the built-in ones + /// (content-type/accept/authorization). Used for provider identity + /// headers and auth-exchange-derived headers. Empty by default. + /// Borrowed; valid as long as this config is. + extra_headers: []const Header = &.{}, }; pub const AnthropicMessagesConfig = struct { @@ -113,6 +128,9 @@ pub const AnthropicMessagesConfig = struct { /// history so prefixes are never reused. There the 1.25x write is pure /// overhead with no read to amortize it. prompt_cache: bool = true, + /// Caller-supplied request headers merged onto the built-in ones. See + /// `OpenAIChatConfig.extra_headers`. Empty by default. + extra_headers: []const Header = &.{}, }; /// Per-provider transport/auth/model configuration. Tagged by `APIStyle`. diff --git a/libpanto/src/public.zig b/libpanto/src/public.zig index 277b4ea..2ee44dd 100644 --- a/libpanto/src/public.zig +++ b/libpanto/src/public.zig @@ -29,6 +29,7 @@ const std = @import("std"); const config_mod = @import("config.zig"); +const auth_mod = @import("auth.zig"); const conversation_mod = @import("conversation.zig"); const agent_mod = @import("agent.zig"); const stream_mod = @import("stream.zig"); @@ -69,6 +70,21 @@ pub const Effort = config_mod.Effort; pub const CompactionConfig = config_mod.CompactionConfig; pub const RetryConfig = config_mod.RetryConfig; pub const WireIdentity = config_mod.WireIdentity; +pub const Header = config_mod.Header; + +// =========================================================================== +// Auth (data + flows, aliased) +// =========================================================================== + +pub const AuthConfig = auth_mod.AuthConfig; +pub const AuthType = auth_mod.AuthType; +pub const ApiKeyAuth = auth_mod.ApiKeyAuth; +pub const OAuthDeviceAuth = auth_mod.OAuthDeviceAuth; +pub const ExchangeConfig = auth_mod.ExchangeConfig; +pub const DeviceDialect = auth_mod.DeviceDialect; +pub const TokenRequestFormat = auth_mod.TokenRequestFormat; +pub const TokenSet = auth_mod.TokenSet; +pub const ResolvedCredential = auth_mod.ResolvedCredential; // =========================================================================== // Conversation construction (data, aliased) @@ -205,6 +221,7 @@ test { // Internal modules' tests (not part of the public surface, but their // tests must still run). std.testing.refAllDecls(config_mod); + std.testing.refAllDecls(auth_mod); std.testing.refAllDecls(conversation_mod); std.testing.refAllDecls(agent_mod); std.testing.refAllDecls(stream_mod); -- cgit v1.3