diff options
Diffstat (limited to 'libpanto/src/auth.zig')
| -rw-r--r-- | libpanto/src/auth.zig | 135 |
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)); |
