summaryrefslogtreecommitdiff
path: root/src/auth_manager.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-13 12:09:00 -0600
committert <t@tjp.lol>2026-06-15 15:08:32 -0600
commit73324a15aa524cf75deebc4172f5a1f0d0051bc6 (patch)
tree85c49f695b3cc5e2b3bbce63e9408ca3ff9b3cf3 /src/auth_manager.zig
parent1b5917e9eb309b4b78e8d2b88b66e7f7bc1834ef (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.zig291
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));
+}