diff options
| author | t <t@tjp.lol> | 2026-06-13 12:09:00 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-15 15:08:32 -0600 |
| commit | 73324a15aa524cf75deebc4172f5a1f0d0051bc6 (patch) | |
| tree | 85c49f695b3cc5e2b3bbce63e9408ca3ff9b3cf3 /src/auth_manager.zig | |
| parent | 1b5917e9eb309b4b78e8d2b88b66e7f7bc1834ef (diff) | |
auth: CLI auth manager + `panto auth` subcommands
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.
Diffstat (limited to 'src/auth_manager.zig')
| -rw-r--r-- | src/auth_manager.zig | 291 |
1 files changed, 291 insertions, 0 deletions
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/<name>.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.<name>]`. + 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)); +} |
