//! Turn-time auth resolution for the panto CLI. //! //! Before each turn the active provider's named auth session is resolved into //! a request-ready credential and patched into the live `panto.Config`: //! //! - `api_key` sessions are already resolved at config load (literal/env); //! nothing to do here. //! - `oauth_device` sessions are loaded from `/auth/.json`, //! refreshed if the access token is near expiry, run through the optional //! secondary exchange (Copilot) if that token is stale, then turned into a //! `ResolvedCredential` (bearer + dynamic base_url + auth-derived headers). //! If no token is stored and a `Presenter` is available, an interactive //! device login runs first. //! //! The mechanism (HTTP flows, JWT parsing, token storage) lives in libpanto's //! `auth` module; this is the embedder-side composition: it owns where tokens //! live, the live-config patching, and the credential's lifetime. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const panto = @import("panto"); const config_file = @import("config_file.zig"); /// Seconds of slack before an access/exchange token's expiry at which we /// proactively refresh. Generous enough to cover a slow turn. pub const refresh_margin_secs: i64 = 120; pub const Error = error{ /// The provider's auth session names an unknown `[auth.]`. UnknownAuth, /// An OAuth session has no stored token and no way to log in now. LoginRequired, } || panto.AuthError || Allocator.Error || error{Unexpected}; /// Owns the credential allocations for the *current* turn. Resolving a new /// turn resets the arena, so the live config must be re-patched each turn /// (which the resolve path does). pub const AuthManager = struct { gpa: Allocator, io: Io, client: *std.http.Client, /// `/auth`. Borrowed for the manager's lifetime. auth_dir: []const u8, file_cfg: *const config_file.Config, /// Arena for the resolved credential strings the live config borrows. cred_arena: std.heap.ArenaAllocator, pub fn init( gpa: Allocator, io: Io, client: *std.http.Client, auth_dir: []const u8, file_cfg: *const config_file.Config, ) AuthManager { return .{ .gpa = gpa, .io = io, .client = client, .auth_dir = auth_dir, .file_cfg = file_cfg, .cred_arena = std.heap.ArenaAllocator.init(gpa), }; } pub fn deinit(self: *AuthManager) void { self.cred_arena.deinit(); } /// Current wall-clock time in unix seconds. fn nowUnix(self: *AuthManager) i64 { const ns = std.Io.Clock.now(.real, self.io).nanoseconds; return @intCast(@divFloor(ns, std.time.ns_per_s)); } /// Resolve the auth for `provider_name` and patch `live` in place /// (api_key, base_url override, merged extra_headers). `force` skips the /// freshness check and forces a refresh/exchange (used after a 401/403). /// `presenter` enables interactive login when no token is stored. /// /// For `api_key` providers this is a no-op (the key is already in `live`). pub fn resolveInto( self: *AuthManager, live: *panto.Config, provider_name: []const u8, force: bool, presenter: ?panto.Presenter, ) anyerror!void { const prov = self.file_cfg.provider(provider_name) orelse return; const auth = self.file_cfg.auth(prov.auth_name) orelse return Error.UnknownAuth; switch (auth.config) { // Already baked into `live` by buildProviderConfig. .api_key => return, .oauth_device => |oauth| { _ = self.cred_arena.reset(.retain_capacity); const arena = self.cred_arena.allocator(); // The provider's identity headers ride on every auth HTTP call // (exchange/device/poll), the same way they ride on the model // request. const cred = try self.resolveOAuth(arena, oauth, prov.auth_name, prov.extra_headers, force, presenter); try patchCredential(live, prov, cred, arena); }, } } /// Compose the OAuth lifecycle: load → (login) → (refresh) → (exchange) → /// build credential. Persists the token after any mutation. `arena` owns /// the returned credential's strings. fn resolveOAuth( self: *AuthManager, arena: Allocator, oauth: panto.OAuthDeviceAuth, name: []const u8, headers: []const panto.Header, force: bool, presenter: ?panto.Presenter, ) anyerror!panto.ResolvedCredential { const now = self.nowUnix(); var loaded = self.loadToken(name); defer if (loaded) |*l| l.deinit(); // Working token set; strings borrow from `loaded` or `arena`. var ts: panto.TokenSet = if (loaded) |l| l.value else blk: { const p = presenter orelse return Error.LoginRequired; const toks = try panto.oauthLogin(arena, self.io, self.client, oauth, p, headers); const fresh = try panto.tokensToTokenSet(arena, oauth, toks, now); try self.saveToken(name, fresh); break :blk fresh; }; // Refresh the access token if it's near expiry (or forced). if ((force or panto.needsRefresh(ts, now, refresh_margin_secs)) and ts.refresh_token != null) { const toks = try panto.refreshTokens(arena, self.client, oauth, ts.refresh_token.?, headers); var refreshed = try panto.tokensToTokenSet(arena, oauth, toks, now); // Refresh responses may omit the refresh token / id_token; keep // the prior values so the session stays renewable. if (refreshed.refresh_token == null) refreshed.refresh_token = ts.refresh_token; if (refreshed.account_id == null) refreshed.account_id = ts.account_id; // The secondary exchange is recomputed below; drop the stale one. refreshed.exchange = null; ts = refreshed; try self.saveToken(name, ts); } // Run the secondary exchange (Copilot) if its token is stale (or forced). if (oauth.exchange) |exchange| { if (force or panto.needsExchange(oauth, ts, now, refresh_margin_secs)) { const access = ts.access_token orelse return Error.LoginRequired; const ex = try panto.runExchange(arena, self.client, exchange, access, headers); ts.exchange = ex; try self.saveToken(name, ts); } } return try panto.buildCredential(arena, oauth, ts); } fn loadToken(self: *AuthManager, name: []const u8) ?panto.ParsedTokenSet { return (panto.loadTokenSet(self.gpa, self.io, self.auth_dir, name) catch return null); } fn saveToken(self: *AuthManager, name: []const u8, ts: panto.TokenSet) !void { try panto.saveTokenSet(self.gpa, self.io, self.auth_dir, name, ts); } }; /// When an auth exchange returns only an origin/base host but the provider's /// configured base_url carries an API path segment, preserve that path on the /// override. This keeps dynamic-host exchanges from accidentally dropping a /// required prefix for providers that still encode one in `base_url`. fn mergeBaseUrlOverride( arena: Allocator, style: panto.APIStyle, provider_base_url: []const u8, override_base_url: ?[]const u8, ) ![]const u8 { const over = override_base_url orelse return provider_base_url; if (style != .anthropic_messages) return over; const prov_uri = std.Uri.parse(provider_base_url) catch return over; const over_uri = std.Uri.parse(over) catch return over; const prov_path = prov_uri.path.percent_encoded; const over_path = over_uri.path.percent_encoded; const prov_has_path = prov_path.len > 0 and !std.mem.eql(u8, prov_path, "/"); const over_has_path = over_path.len > 0 and !std.mem.eql(u8, over_path, "/"); if (!prov_has_path or over_has_path) return over; return try std.fmt.allocPrint(arena, "{s}{s}", .{ over, prov_path }); } /// Patch a resolved credential into the live provider config: set the bearer/ /// key, override base_url when the credential supplies one, and merge the /// provider's static `extra_headers` with the auth-derived ones (into `arena`). fn patchCredential( live: *panto.Config, prov: *const config_file.Provider, cred: panto.ResolvedCredential, arena: Allocator, ) !void { // Combine provider-static headers (stable, owned by the file config) with // the auth-derived headers (owned by `arena`). const merged = try arena.alloc(panto.Header, prov.extra_headers.len + cred.extra_headers.len); @memcpy(merged[0..prov.extra_headers.len], prov.extra_headers); @memcpy(merged[prov.extra_headers.len..], cred.extra_headers); const base_url = try mergeBaseUrlOverride(arena, prov.style, prov.base_url, cred.base_url_override); switch (live.provider) { .openai_chat => |*c| { c.api_key = cred.api_key; c.base_url = base_url; c.extra_headers = merged; }, .anthropic_messages => |*c| { c.api_key = cred.api_key; c.base_url = base_url; c.extra_headers = merged; }, .openai_responses => |*c| { c.api_key = cred.api_key; c.base_url = base_url; c.extra_headers = merged; }, .openai_codex_responses => |*c| { c.api_key = cred.api_key; c.base_url = base_url; c.extra_headers = merged; }, } } const t = std.testing; test "patchCredential: injects key, base_url override, merged headers" { var aa = std.heap.ArenaAllocator.init(t.allocator); defer aa.deinit(); const arena = aa.allocator(); const prov: config_file.Provider = .{ .name = "copilot", .style = .openai_chat, .base_url = "https://placeholder", .auth_name = "github_copilot", .extra_headers = &.{.{ .name = "Copilot-Integration-Id", .value = "vscode-chat" }}, }; var live: panto.Config = .{ .provider = .{ .openai_chat = .{ .api_key = "", .base_url = "https://placeholder", .model = "gpt-4o", } } }; const cred: panto.ResolvedCredential = .{ .api_key = "exchanged-token", .base_url_override = "https://api.individual.githubcopilot.com", .extra_headers = &.{.{ .name = "chatgpt-account-id", .value = "acct" }}, }; try patchCredential(&live, &prov, cred, arena); try t.expectEqualStrings("exchanged-token", live.provider.openai_chat.api_key); try t.expectEqualStrings("https://api.individual.githubcopilot.com", live.provider.openai_chat.base_url); try t.expectEqual(@as(usize, 2), live.provider.openai_chat.extra_headers.len); try t.expectEqualStrings("Copilot-Integration-Id", live.provider.openai_chat.extra_headers[0].name); try t.expectEqualStrings("chatgpt-account-id", live.provider.openai_chat.extra_headers[1].name); } test "patchCredential: anthropic oauth override keeps origin base_url" { var aa = std.heap.ArenaAllocator.init(t.allocator); defer aa.deinit(); const arena = aa.allocator(); const prov: config_file.Provider = .{ .name = "copilot-anthropic", .style = .anthropic_messages, .base_url = "https://api.individual.githubcopilot.com", .auth_name = "github_copilot", }; var live: panto.Config = .{ .provider = .{ .anthropic_messages = .{ .api_key = "", .base_url = prov.base_url, .model = "claude-sonnet-4-5", } } }; const cred: panto.ResolvedCredential = .{ .api_key = "exchanged-token", .base_url_override = "https://api.individual.githubcopilot.com", }; try patchCredential(&live, &prov, cred, arena); try t.expectEqualStrings("https://api.individual.githubcopilot.com", live.provider.anthropic_messages.base_url); } test "resolveInto: api_key auth is a no-op" { const a = t.allocator; var env = std.process.Environ.Map.init(a); defer env.deinit(); try env.put("OPENAI_API_KEY", "sk-live"); const src = \\[providers.openai] \\style = "openai_chat" \\base_url = "https://api.openai.com/v1" \\auth = "openai_api" \\ \\[auth.openai_api] \\type = "api_key" \\key_env_var = "OPENAI_API_KEY" ; var cfg = try config_file.loadFromString(a, &env, src); defer cfg.deinit(); var live: panto.Config = .{ .provider = .{ .openai_chat = .{ .api_key = "sk-live", .base_url = "https://api.openai.com/v1", .model = "gpt-4o", } } }; var client: std.http.Client = undefined; // unused on the api_key path var mgr = AuthManager.init(a, t.io, &client, "/nonexistent", &cfg); defer mgr.deinit(); try mgr.resolveInto(&live, "openai", false, null); // Unchanged. try t.expectEqualStrings("sk-live", live.provider.openai_chat.api_key); } test "resolveInto: oauth with no token and no presenter requires login" { const a = t.allocator; var env = std.process.Environ.Map.init(a); defer env.deinit(); const src = \\[providers.copilot] \\style = "openai_chat" \\base_url = "https://api.individual.githubcopilot.com" \\auth = "github_copilot" \\ \\[auth.github_copilot] \\type = "oauth_device" \\client_id = "Iv1.x" \\device_code_url = "https://github.com/login/device/code" \\token_url = "https://github.com/login/oauth/access_token" ; var cfg = try config_file.loadFromString(a, &env, src); defer cfg.deinit(); var live: panto.Config = .{ .provider = .{ .openai_chat = .{ .api_key = "", .base_url = "https://api.individual.githubcopilot.com", .model = "gpt-4o", } } }; var client: std.http.Client = undefined; var mgr = AuthManager.init(a, t.io, &client, "/nonexistent/auth", &cfg); defer mgr.deinit(); try t.expectError(Error.LoginRequired, mgr.resolveInto(&live, "copilot", false, null)); }