From 73324a15aa524cf75deebc4172f5a1f0d0051bc6 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 13 Jun 2026 12:09:00 -0600 Subject: auth: CLI auth manager + `panto auth` subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/auth_manager.zig (turn-time resolution: load→login→refresh→exchange→ build credential, patch live config) and `panto auth status|login|logout` subcommands with a line-based device-code presenter. Add config_file.loadFromString. --- src/auth_manager.zig | 291 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/config_file.zig | 12 +++ src/main.zig | 2 + src/subcommand.zig | 207 ++++++++++++++++++++++++++++++++++++ 4 files changed, 512 insertions(+) create mode 100644 src/auth_manager.zig (limited to 'src') diff --git a/src/auth_manager.zig b/src/auth_manager.zig new file mode 100644 index 0000000..263ec54 --- /dev/null +++ b/src/auth_manager.zig @@ -0,0 +1,291 @@ +//! 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 `$PANTO_HOME/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, + /// `$PANTO_HOME/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(); + const cred = try self.resolveOAuth(arena, oauth, prov.auth_name, 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, + 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); + 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.?); + 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); + 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); + } +}; + +/// 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 = cred.base_url_override orelse prov.base_url; + 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; + }, + } +} + +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 "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)); +} diff --git a/src/config_file.zig b/src/config_file.zig index 19e4bb1..1277661 100644 --- a/src/config_file.zig +++ b/src/config_file.zig @@ -398,6 +398,18 @@ pub fn userConfigPath(allocator: Allocator, environ_map: *const std.process.Envi return error.NoHomeDirectory; } +/// Resolve a `Config` from a single in-memory TOML string. Convenience for +/// tests and tooling that already hold the document text. +pub fn loadFromString( + allocator: Allocator, + environ_map: *const std.process.Environ.Map, + source: []const u8, +) Error!Config { + const doc = parseDoc(allocator, source) catch return error.InvalidConfigToml; + defer doc.deinit(); + return resolve(allocator, environ_map, doc.root); +} + /// Load from explicit layer paths, lowest precedence first. Useful for /// tests. Missing files are skipped. pub fn loadFromPaths( diff --git a/src/main.zig b/src/main.zig index e867c0e..dc611b4 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,6 +11,7 @@ const subcommand = @import("subcommand.zig"); const session_paths = @import("session_paths.zig"); const models_toml = @import("models_toml.zig"); const config_file = @import("config_file.zig"); +const auth_manager = @import("auth_manager.zig"); const glob = @import("glob.zig"); const system_prompt = @import("system_prompt.zig"); const command = @import("command.zig"); @@ -54,6 +55,7 @@ test { _ = subcommand; _ = models_toml; _ = config_file; + _ = auth_manager; _ = glob; _ = system_prompt; _ = command; diff --git a/src/subcommand.zig b/src/subcommand.zig index 21d2065..b145d4b 100644 --- a/src/subcommand.zig +++ b/src/subcommand.zig @@ -24,6 +24,8 @@ const lua_bridge = @import("lua_bridge.zig"); const luarocks_runtime = @import("luarocks_runtime.zig"); const self_exe = @import("self_exe.zig"); const session_paths = @import("session_paths.zig"); +const config_file = @import("config_file.zig"); +const panto_home = @import("panto_home.zig"); const panto = @import("panto"); const c = lua_bridge.c; @@ -78,6 +80,10 @@ pub fn dispatch( try runSessionsSubcommand(allocator, io, environ_map); return .done; } + if (std.mem.eql(u8, sub, "auth")) { + try runAuthSubcommand(allocator, io, environ_map, &it); + return .done; + } if (std.mem.eql(u8, sub, "--help") or std.mem.eql(u8, sub, "-h") or std.mem.eql(u8, sub, "help")) { try printHelp(io); return .done; @@ -97,6 +103,11 @@ fn printHelp(io: Io) !void { \\ panto --resume Resume the most recent conversation in this directory. \\ panto --resume Resume the conversation whose id begins with . \\ panto sessions List saved sessions for this directory. + \\ panto auth status Show configured auth sessions and login state. + \\ panto auth login + \\ Log in to an OAuth auth session (device flow). + \\ panto auth logout + \\ Forget a stored OAuth token. \\ panto bootstrap [--force] \\ Run the luarocks bootstrap and exit. \\ panto lua [args...] Drop into the embedded Lua interpreter. @@ -312,6 +323,202 @@ fn trimCreated(iso: []const u8) []const u8 { return iso[0..16]; } +// --------------------------------------------------------------------------- +// `panto auth` +// --------------------------------------------------------------------------- + +/// Line-based device-code presenter for the `panto auth login` flow (the TUI +/// is not running here). Prints the verification URL + user code to stdout. +const CliPresenter = struct { + io: Io, + + fn deviceCode(ptr: *anyopaque, prompt: panto.DeviceCodePrompt) void { + const self: *CliPresenter = @ptrCast(@alignCast(ptr)); + var buf: [1024]u8 = undefined; + var fw = std.Io.File.stdout().writer(self.io, &buf); + const w = &fw.interface; + w.print( + "\nTo authorize, open this URL in a browser:\n {s}\n\nand enter the code:\n {s}\n\n", + .{ prompt.verification_uri, prompt.user_code }, + ) catch {}; + fw.flush() catch {}; + } + + fn status(ptr: *anyopaque, msg: []const u8) void { + const self: *CliPresenter = @ptrCast(@alignCast(ptr)); + var buf: [256]u8 = undefined; + var fw = std.Io.File.stdout().writer(self.io, &buf); + const w = &fw.interface; + w.print("{s}\n", .{msg}) catch {}; + fw.flush() catch {}; + } + + const vtable: panto.Presenter.VTable = .{ + .on_device_code = deviceCode, + .on_status = status, + }; + + fn presenter(self: *CliPresenter) panto.Presenter { + return .{ .ptr = self, .vtable = &vtable }; + } +}; + +fn nowUnix(io: Io) i64 { + const ns = std.Io.Clock.now(.real, io).nanoseconds; + return @intCast(@divFloor(ns, std.time.ns_per_s)); +} + +/// `panto auth [status|login |logout ]`. +fn runAuthSubcommand( + allocator: Allocator, + io: Io, + environ_map: *const std.process.Environ.Map, + it: *std.process.Args.Iterator, +) !void { + var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; + const cwd_n = try std.process.currentPath(io, &cwd_buf); + const cwd = cwd_buf[0..cwd_n]; + + var cfg = config_file.load(allocator, io, environ_map, cwd) catch |err| { + std.log.err("panto auth: failed to load config ({t})", .{err}); + return err; + }; + defer cfg.deinit(); + + var layout = try panto_home.resolve(allocator, environ_map); + defer layout.deinit(); + const auth_dir = layout.auth_dir; + + var out_buf: [4096]u8 = undefined; + var out_file = std.Io.File.stdout().writer(io, &out_buf); + const out = &out_file.interface; + + const action = it.next() orelse "status"; + + if (std.mem.eql(u8, action, "status")) { + try authStatus(allocator, io, &cfg, auth_dir, out); + try out_file.flush(); + return; + } + if (std.mem.eql(u8, action, "logout")) { + const name = it.next() orelse { + try out.writeAll("usage: panto auth logout \n"); + try out_file.flush(); + return; + }; + const removed = try panto.deleteTokenSet(allocator, io, auth_dir, name); + if (removed) { + try out.print("logged out of '{s}'\n", .{name}); + } else { + try out.print("no stored token for '{s}'\n", .{name}); + } + try out_file.flush(); + return; + } + if (std.mem.eql(u8, action, "login")) { + const name = it.next() orelse { + try out.writeAll("usage: panto auth login \n"); + try out_file.flush(); + return; + }; + try authLogin(allocator, io, &cfg, auth_dir, name, out); + try out_file.flush(); + return; + } + + try out.print("unknown auth action '{s}' (try: status, login, logout)\n", .{action}); + try out_file.flush(); +} + +fn authStatus( + allocator: Allocator, + io: Io, + cfg: *const config_file.Config, + auth_dir: []const u8, + out: *std.Io.Writer, +) !void { + if (cfg.auths.len == 0) { + try out.writeAll("no auth sessions configured\n"); + return; + } + const now = nowUnix(io); + for (cfg.auths) |a| { + switch (a.config) { + .api_key => { + const state = if (a.resolved_api_key != null) "resolved" else "unresolved (key/env missing)"; + try out.print("{s} api_key {s}\n", .{ a.name, state }); + }, + .oauth_device => { + var loaded = panto.loadTokenSet(allocator, io, auth_dir, a.name) catch null; + defer if (loaded) |*l| l.deinit(); + if (loaded) |l| { + const ts = l.value; + if (ts.expires_at) |exp| { + const mins = @divFloor(exp - now, 60); + try out.print("{s} oauth_device logged in (access expires in ~{d}m)\n", .{ a.name, mins }); + } else { + try out.print("{s} oauth_device logged in\n", .{a.name}); + } + } else { + try out.print("{s} oauth_device not logged in (run: panto auth login {s})\n", .{ a.name, a.name }); + } + }, + } + } +} + +fn authLogin( + allocator: Allocator, + io: Io, + cfg: *const config_file.Config, + auth_dir: []const u8, + name: []const u8, + out: *std.Io.Writer, +) !void { + const a = cfg.auth(name) orelse { + try out.print("no auth session named '{s}' in config\n", .{name}); + return; + }; + const oauth = switch (a.config) { + .oauth_device => |o| o, + .api_key => { + try out.print("'{s}' is an api_key session; nothing to log in to\n", .{name}); + return; + }, + }; + + panto.init(allocator, io); + defer panto.deinit(); + const client = panto.httpClient(); + + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const aa = arena.allocator(); + + var presenter = CliPresenter{ .io = io }; + const toks = panto.oauthLogin(aa, io, client, oauth, presenter.presenter()) catch |err| { + try out.print("login failed: {t}\n", .{err}); + return; + }; + + const now = nowUnix(io); + var ts = try panto.tokensToTokenSet(aa, oauth, toks, now); + + // Run the secondary exchange now (if configured) so the first turn is + // immediately usable and we surface any exchange error during login. + if (oauth.exchange) |exchange| { + if (ts.access_token) |access| { + ts.exchange = panto.runExchange(aa, client, exchange, access) catch |err| blk: { + try out.print("note: token exchange failed ({t}); will retry on first use\n", .{err}); + break :blk null; + }; + } + } + + try panto.saveTokenSet(allocator, io, auth_dir, name, ts); + try out.print("\nauthorized — '{s}' is ready to use.\n", .{name}); +} + // --------------------------------------------------------------------------- // `panto lua` argv plumbing — sketched against the older Args API for // reference (kept here so the design notes survive the implementation). -- cgit v1.3