summaryrefslogtreecommitdiff
path: root/libpanto
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-13 11:55:59 -0600
committert <t@tjp.lol>2026-06-15 15:08:32 -0600
commitfe2bfe443dfcf3251ebe39002325f604634dfa1f (patch)
tree83cc6673f85b85f5c49f8b1c5195e2ad68ca9002 /libpanto
parent9308feabc045a3afd7eeb304a2324d17d03246df (diff)
auth: HTTP helper + token storage (C3, C4)
Add libpanto http_helper (non-streaming request/response over the global client, plus dotted-JSON-path readers) and token persistence in auth.zig (load/save/delete TokenSet under an embedder-chosen auth dir, owner-only files). Add $PANTO_HOME/auth to the panto_home layout.
Diffstat (limited to 'libpanto')
-rw-r--r--libpanto/src/auth.zig135
-rw-r--r--libpanto/src/http_helper.zig160
-rw-r--r--libpanto/src/public.zig12
3 files changed, 307 insertions, 0 deletions
diff --git a/libpanto/src/auth.zig b/libpanto/src/auth.zig
index 012e495..19e08fe 100644
--- a/libpanto/src/auth.zig
+++ b/libpanto/src/auth.zig
@@ -22,6 +22,8 @@
//! `$PANTO_HOME` is and how a device code is shown to the user.
const std = @import("std");
+const builtin = @import("builtin");
+const Io = std.Io;
const config_mod = @import("config.zig");
/// A single request header. Re-exported from `config.zig` so callers can
@@ -153,8 +155,141 @@ pub const ResolvedCredential = struct {
extra_headers: []const Header = &.{},
};
+// ===========================================================================
+// Token storage (`$PANTO_HOME/auth/<name>.json`)
+// ===========================================================================
+
+/// A parsed `TokenSet` plus the arena owning its strings. Call `deinit`.
+pub const ParsedTokenSet = std.json.Parsed(TokenSet);
+
+/// Owner-only file permissions for token files (POSIX 0o600). On Windows the
+/// platform `Permissions` enum has no mode, so fall back to its default.
+fn tokenFilePermissions() Io.File.Permissions {
+ if (builtin.os.tag == .windows) return .default_file;
+ return Io.File.Permissions.fromMode(0o600);
+}
+
+/// Load and parse `<auth_dir>/<name>.json`. Returns null if the file does not
+/// exist. The caller owns the result and must `deinit` it.
+pub fn loadTokenSet(
+ allocator: std.mem.Allocator,
+ io: Io,
+ auth_dir: []const u8,
+ name: []const u8,
+) !?ParsedTokenSet {
+ const fname = try std.fmt.allocPrint(allocator, "{s}.json", .{name});
+ defer allocator.free(fname);
+
+ var dir = Io.Dir.cwd().openDir(io, auth_dir, .{}) catch |err| switch (err) {
+ error.FileNotFound => return null,
+ else => return err,
+ };
+ defer dir.close(io);
+
+ const bytes = dir.readFileAlloc(io, fname, allocator, .limited(1 << 20)) catch |err| switch (err) {
+ error.FileNotFound => return null,
+ else => return err,
+ };
+ defer allocator.free(bytes);
+
+ return try std.json.parseFromSlice(TokenSet, allocator, bytes, .{
+ .ignore_unknown_fields = true,
+ .allocate = .alloc_always,
+ });
+}
+
+/// Serialize and write `ts` to `<auth_dir>/<name>.json` with owner-only
+/// permissions, creating `auth_dir` if needed. Null optional fields are
+/// omitted to keep the file tidy.
+pub fn saveTokenSet(
+ allocator: std.mem.Allocator,
+ io: Io,
+ auth_dir: []const u8,
+ name: []const u8,
+ ts: TokenSet,
+) !void {
+ Io.Dir.cwd().createDirPath(io, auth_dir) catch |err| switch (err) {
+ error.PathAlreadyExists => {},
+ else => return err,
+ };
+
+ const json = try std.json.Stringify.valueAlloc(allocator, ts, .{
+ .emit_null_optional_fields = false,
+ .whitespace = .indent_2,
+ });
+ defer allocator.free(json);
+
+ const fname = try std.fmt.allocPrint(allocator, "{s}.json", .{name});
+ defer allocator.free(fname);
+
+ var dir = try Io.Dir.cwd().openDir(io, auth_dir, .{});
+ defer dir.close(io);
+ try dir.writeFile(io, .{
+ .sub_path = fname,
+ .data = json,
+ .flags = .{ .permissions = tokenFilePermissions() },
+ });
+}
+
+/// Delete `<auth_dir>/<name>.json`. Returns true if a file was removed, false
+/// if it did not exist (idempotent logout).
+pub fn deleteTokenSet(
+ allocator: std.mem.Allocator,
+ io: Io,
+ auth_dir: []const u8,
+ name: []const u8,
+) !bool {
+ const fname = try std.fmt.allocPrint(allocator, "{s}.json", .{name});
+ defer allocator.free(fname);
+ var dir = Io.Dir.cwd().openDir(io, auth_dir, .{}) catch |err| switch (err) {
+ error.FileNotFound => return false,
+ else => return err,
+ };
+ defer dir.close(io);
+ dir.deleteFile(io, fname) catch |err| switch (err) {
+ error.FileNotFound => return false,
+ else => return err,
+ };
+ return true;
+}
+
const t = std.testing;
+test "token storage: save, load, delete round-trip" {
+ const io = t.io;
+ var tmp = t.tmpDir(.{});
+ defer tmp.cleanup();
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const n = try tmp.dir.realPath(io, &path_buf);
+ const auth_dir = try std.fmt.allocPrint(t.allocator, "{s}/auth", .{path_buf[0..n]});
+ defer t.allocator.free(auth_dir);
+
+ // Absent => null.
+ try t.expect((try loadTokenSet(t.allocator, io, auth_dir, "ghost")) == null);
+
+ const ts: TokenSet = .{
+ .type = "oauth_device",
+ .access_token = "ghu_abc",
+ .refresh_token = "rt_xyz",
+ .expires_at = 1700000000,
+ .exchange = .{ .token = "tkn", .expires_at = 1700001800, .base_url = "https://api.x" },
+ };
+ try saveTokenSet(t.allocator, io, auth_dir, "github_copilot", ts);
+
+ var loaded = (try loadTokenSet(t.allocator, io, auth_dir, "github_copilot")).?;
+ defer loaded.deinit();
+ try t.expectEqualStrings("ghu_abc", loaded.value.access_token.?);
+ try t.expectEqualStrings("rt_xyz", loaded.value.refresh_token.?);
+ try t.expectEqual(@as(?i64, 1700000000), loaded.value.expires_at);
+ try t.expect(loaded.value.id_token == null);
+ try t.expectEqualStrings("tkn", loaded.value.exchange.?.token);
+ try t.expectEqualStrings("https://api.x", loaded.value.exchange.?.base_url.?);
+
+ try t.expect(try deleteTokenSet(t.allocator, io, auth_dir, "github_copilot"));
+ try t.expect(!try deleteTokenSet(t.allocator, io, auth_dir, "github_copilot"));
+ try t.expect((try loadTokenSet(t.allocator, io, auth_dir, "github_copilot")) == null);
+}
+
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));
diff --git a/libpanto/src/http_helper.zig b/libpanto/src/http_helper.zig
new file mode 100644
index 0000000..a075d72
--- /dev/null
+++ b/libpanto/src/http_helper.zig
@@ -0,0 +1,160 @@
+//! Small non-streaming HTTP request/response helper over the process-global
+//! `std.http.Client`. The streaming providers deliberately avoid `fetch()`
+//! (it buffers); auth flows want the opposite — request a URL, read the whole
+//! response body into one buffer, inspect status + JSON. This is that.
+//!
+//! Used by `auth.zig` for OAuth device flows and token exchanges. Not part of
+//! the streaming hot path, so simplicity wins: one request, full-body read,
+//! owned result.
+
+const std = @import("std");
+const config_mod = @import("config.zig");
+
+pub const Header = config_mod.Header;
+pub const Method = std.http.Method;
+
+/// A fully-read HTTP response. `body` is owned by `allocator`.
+pub const Response = struct {
+ allocator: std.mem.Allocator,
+ status: u16,
+ body: []u8,
+
+ pub fn deinit(self: Response) void {
+ self.allocator.free(self.body);
+ }
+
+ /// True for a 2xx status.
+ pub fn ok(self: Response) bool {
+ return self.status >= 200 and self.status < 300;
+ }
+};
+
+pub const Options = struct {
+ /// Caller headers (auth bearer, identity headers, …). `accept` and
+ /// `content-type` are added separately from the fields below.
+ headers: []const Header = &.{},
+ /// Request body, or null for a bodiless request (e.g. a GET).
+ body: ?[]const u8 = null,
+ /// `content-type` header value when `body` is present.
+ content_type: ?[]const u8 = null,
+ /// `accept` header value. Defaults to JSON.
+ accept: ?[]const u8 = "application/json",
+ /// Upper bound on the response body read into memory.
+ max_body_bytes: usize = 1 << 20,
+};
+
+/// Perform one request and return the fully-read response. The caller owns
+/// `Response.body` and must `deinit` it. Redirects are surfaced as their 3xx
+/// status (not followed) so auth credentials never leak across a redirect.
+pub fn request(
+ allocator: std.mem.Allocator,
+ client: *std.http.Client,
+ method: Method,
+ url: []const u8,
+ opts: Options,
+) !Response {
+ const uri = try std.Uri.parse(url);
+
+ var hdrs: std.ArrayList(std.http.Header) = .empty;
+ defer hdrs.deinit(allocator);
+ if (opts.accept) |a| try hdrs.append(allocator, .{ .name = "accept", .value = a });
+ if (opts.content_type) |ct| {
+ if (opts.body != null) try hdrs.append(allocator, .{ .name = "content-type", .value = ct });
+ }
+ for (opts.headers) |h| try hdrs.append(allocator, .{ .name = h.name, .value = h.value });
+
+ var req = try client.request(method, uri, .{
+ .extra_headers = hdrs.items,
+ // Disable compression so we read the body without a decompressor.
+ .headers = .{ .accept_encoding = .{ .override = "identity" } },
+ .keep_alive = false,
+ // Surface 3xx to us rather than following with auth headers attached.
+ .redirect_behavior = .unhandled,
+ });
+ defer req.deinit();
+
+ if (opts.body) |body| {
+ req.transfer_encoding = .{ .content_length = body.len };
+ var send_buf: [4096]u8 = undefined;
+ var bw = try req.sendBodyUnflushed(&send_buf);
+ try bw.writer.writeAll(body);
+ try bw.end();
+ try req.connection.?.flush();
+ } else {
+ try req.sendBodiless();
+ }
+
+ var redirect_buf: [2048]u8 = undefined;
+ var response = try req.receiveHead(&redirect_buf);
+ const status: u16 = @intFromEnum(response.head.status);
+
+ var transfer_buf: [4096]u8 = undefined;
+ const body_reader = response.reader(&transfer_buf);
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(allocator);
+ var tmp: [4096]u8 = undefined;
+ while (true) {
+ const n = body_reader.readSliceShort(&tmp) catch break;
+ if (n == 0) break;
+ try out.appendSlice(allocator, tmp[0..n]);
+ if (out.items.len > opts.max_body_bytes) break;
+ }
+
+ return .{ .allocator = allocator, .status = status, .body = try out.toOwnedSlice(allocator) };
+}
+
+// ===========================================================================
+// JSON helpers (used by auth flows to read fields out of a response body)
+// ===========================================================================
+
+/// Read a string at a dotted JSON path (e.g. `endpoints.api`) from a parsed
+/// JSON value. Returns null if any segment is missing or the leaf is not a
+/// string. Borrows from `root`.
+pub fn jsonStringAtPath(root: std.json.Value, path: []const u8) ?[]const u8 {
+ const leaf = jsonAtPath(root, path) orelse return null;
+ return switch (leaf) {
+ .string => |s| s,
+ else => null,
+ };
+}
+
+/// Read an integer (unix-seconds expiry, etc.) at a dotted JSON path. Accepts
+/// JSON integers and integer-valued floats. Returns null otherwise.
+pub fn jsonIntAtPath(root: std.json.Value, path: []const u8) ?i64 {
+ const leaf = jsonAtPath(root, path) orelse return null;
+ return switch (leaf) {
+ .integer => |i| i,
+ .float => |f| @intFromFloat(f),
+ .number_string => |s| std.fmt.parseInt(i64, s, 10) catch null,
+ else => null,
+ };
+}
+
+/// Walk a dotted path through nested JSON objects. Returns the leaf value or
+/// null if any object segment is missing.
+pub fn jsonAtPath(root: std.json.Value, path: []const u8) ?std.json.Value {
+ var cur = root;
+ var it = std.mem.splitScalar(u8, path, '.');
+ while (it.next()) |seg| {
+ switch (cur) {
+ .object => |o| cur = o.get(seg) orelse return null,
+ else => return null,
+ }
+ }
+ return cur;
+}
+
+const t = std.testing;
+
+test "jsonAtPath: nested object string + int" {
+ const src =
+ \\{"token":"abc","expires_at":1700000000,"endpoints":{"api":"https://x"}}
+ ;
+ var parsed = try std.json.parseFromSlice(std.json.Value, t.allocator, src, .{});
+ defer parsed.deinit();
+ try t.expectEqualStrings("abc", jsonStringAtPath(parsed.value, "token").?);
+ try t.expectEqual(@as(i64, 1700000000), jsonIntAtPath(parsed.value, "expires_at").?);
+ try t.expectEqualStrings("https://x", jsonStringAtPath(parsed.value, "endpoints.api").?);
+ try t.expect(jsonStringAtPath(parsed.value, "endpoints.missing") == null);
+ try t.expect(jsonStringAtPath(parsed.value, "nope.deep") == null);
+}
diff --git a/libpanto/src/public.zig b/libpanto/src/public.zig
index 2ee44dd..aa2f7e8 100644
--- a/libpanto/src/public.zig
+++ b/libpanto/src/public.zig
@@ -84,8 +84,19 @@ 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 ParsedTokenSet = auth_mod.ParsedTokenSet;
pub const ResolvedCredential = auth_mod.ResolvedCredential;
+/// Token persistence under an embedder-chosen auth directory
+/// (`$PANTO_HOME/auth`). Files are written owner-only.
+pub const loadTokenSet = auth_mod.loadTokenSet;
+pub const saveTokenSet = auth_mod.saveTokenSet;
+pub const deleteTokenSet = auth_mod.deleteTokenSet;
+
+/// Non-streaming HTTP helper over the process-global client, plus JSON
+/// path readers — the building blocks of the OAuth flows.
+pub const http = @import("http_helper.zig");
+
// ===========================================================================
// Conversation construction (data, aliased)
// ===========================================================================
@@ -222,6 +233,7 @@ test {
// tests must still run).
std.testing.refAllDecls(config_mod);
std.testing.refAllDecls(auth_mod);
+ std.testing.refAllDecls(@import("http_helper.zig"));
std.testing.refAllDecls(conversation_mod);
std.testing.refAllDecls(agent_mod);
std.testing.refAllDecls(stream_mod);