summaryrefslogtreecommitdiff
path: root/libpanto/src
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-13 11:50:00 -0600
committert <t@tjp.lol>2026-06-15 15:08:32 -0600
commitdeb4ce4a1a76869771bfcdd758455c49815f0d13 (patch)
tree8ab99d3f6af2a159f9aaf67e94aa093eb11985e0 /libpanto/src
parentc3f01bf5e6abfad51d316941a93d9cf328ac6c13 (diff)
auth: clean-break named [auth.<name>] sessions + extra_headers
Replace provider-level api_key/api_key_env_var with named auth sessions: providers now reference `auth = "<name>"` and credentials live under `[auth.<name>]`. Adds the libpanto auth type surface (AuthConfig, TokenSet, ResolvedCredential), a Header type and provider `extra_headers`, and rewires the CLI config loader (parse [auth.<name>], require auth=, providers always survive, eager api_key resolution). Updates the shipped default config.
Diffstat (limited to 'libpanto/src')
-rw-r--r--libpanto/src/auth.zig180
-rw-r--r--libpanto/src/config.zig18
-rw-r--r--libpanto/src/public.zig17
3 files changed, 215 insertions, 0 deletions
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 = "<name>"`); 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.<name>]`)
+// ===========================================================================
+
+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/<name>.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);