summaryrefslogtreecommitdiff
path: root/libpanto/src/auth.zig
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/src/auth.zig
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/src/auth.zig')
-rw-r--r--libpanto/src/auth.zig135
1 files changed, 135 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));