diff --git a/README.md b/README.md index 37f120eb0..43b4a64e3 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,9 @@ fx `fx login codex` and `fx login grok` select that provider and a model from its authenticated catalog. Inside fx, open `/setup` and choose **Model provider** to move between Gateway, Codex, and Grok. `/model` lists the active provider's fetched models. Subscription model IDs are the raw IDs returned by each authenticated catalog. Use `/logout codex` or `/logout grok` to remove that subscription session without affecting other providers; choosing it again from **Model provider** starts sign-in. -The OpenAI Codex route uses ChatGPT subscription access directly and never sends its OAuth token to Vercel AI Gateway. The session is stored privately at `~/.fx/chatgpt-auth.json` and refreshed when needed. On supported Codex models, `/fast` requests OpenAI's priority service tier and consumes ChatGPT credits at the higher Fast mode rate. +The OpenAI Codex route uses ChatGPT subscription access directly and never sends its OAuth token to Vercel AI Gateway. The session is stored with the other model-provider credentials in fx's private auth store and refreshed when needed. On supported Codex models, `/fast` requests OpenAI's priority service tier and consumes ChatGPT credits at the higher Fast mode rate. -The Grok route uses subscription access directly at xAI and never sends its OAuth token to Vercel AI Gateway or OpenAI. Its session is stored privately at `~/.fx/grok-auth.json`, refreshed when needed, and used only with the authenticated xAI catalog and Responses API. +The Grok route uses subscription access directly at xAI and never sends its OAuth token to Vercel AI Gateway or OpenAI. Its session is stored in the same private auth store, refreshed when needed, and used only with the authenticated xAI catalog and Responses API. The portable backend keeps that store at `~/.fx/auth.json`. To use an AI Gateway API key instead: diff --git a/src/core/auth/auth_store.zig b/src/core/auth/auth_store.zig new file mode 100644 index 000000000..3c0a222dd --- /dev/null +++ b/src/core/auth/auth_store.zig @@ -0,0 +1,352 @@ +const std = @import("std"); +const secret = @import("secret.zig"); + +const Allocator = std.mem.Allocator; + +pub const StoredSource = enum { + stored_key, + fx_login, + chatgpt_subscription, + grok_subscription, +}; + +pub const StoreState = enum { + empty, + legacy, + current, + malformed_current, +}; + +pub const LoadIntent = enum { + inspect, + active, +}; + +pub const LoadDecision = enum { + missing, + use_legacy, + migrate_legacy, + use_current, + reject_current, +}; + +pub fn decide_load(state: StoreState, intent: LoadIntent) LoadDecision { + return switch (state) { + .empty => .missing, + .legacy => if (intent == .inspect) .use_legacy else .migrate_legacy, + .current => .use_current, + .malformed_current => .reject_current, + }; +} + +const slot_count = std.meta.fields(StoredSource).len; + +pub const Document = struct { + slots: [slot_count]?[]u8 = [_]?[]u8{null} ** slot_count, + + pub fn deinit(self: *Document, alloc: Allocator) void { + for (&self.slots) |*slot| { + if (slot.*) |value| secret.zeroAndFree(alloc, value); + slot.* = null; + } + self.* = .{}; + } + + pub fn get(self: *const Document, source: StoredSource) ?[]const u8 { + return self.slots[@intFromEnum(source)]; + } + + pub fn replaced( + self: *const Document, + alloc: Allocator, + source: StoredSource, + value: []const u8, + ) !Document { + if (source == .stored_key) { + try validate_entry(alloc, source, value); + return self.transformed(alloc, source, value); + } + var parsed = std.json.parseFromSlice(std.json.Value, alloc, value, .{}) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.InvalidAuthDocument, + }; + defer parsed.deinit(); + if (parsed.value != .object) return error.InvalidAuthDocument; + const canonical = try stringify_value(alloc, parsed.value); + defer secret.zeroAndFree(alloc, canonical); + return self.transformed(alloc, source, canonical); + } + + pub fn removed( + self: *const Document, + alloc: Allocator, + source: StoredSource, + ) !Document { + return self.transformed(alloc, source, null); + } + + pub fn eql(self: *const Document, other: Document) bool { + for (std.meta.tags(StoredSource)) |source| { + const left = self.get(source); + const right = other.get(source); + if (left == null or right == null) { + if (left != null or right != null) return false; + continue; + } + if (!std.mem.eql(u8, left.?, right.?)) return false; + } + return true; + } + + pub fn parse(alloc: Allocator, bytes: []const u8) !Document { + var parsed = std.json.parseFromSlice(std.json.Value, alloc, bytes, .{}) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.InvalidAuthDocument, + }; + defer parsed.deinit(); + if (parsed.value != .object or parsed.value.object.count() != 2) { + return error.InvalidAuthDocument; + } + const version = parsed.value.object.get("version") orelse return error.InvalidAuthDocument; + if (version != .integer or version.integer != 2) return error.InvalidAuthDocument; + const credentials = parsed.value.object.get("credentials") orelse return error.InvalidAuthDocument; + if (credentials != .object) return error.InvalidAuthDocument; + + var document: Document = .{}; + errdefer document.deinit(alloc); + var iterator = credentials.object.iterator(); + while (iterator.next()) |item| { + const source = std.meta.stringToEnum(StoredSource, item.key_ptr.*) orelse + return error.InvalidAuthDocument; + if (item.value_ptr.* != .object or item.value_ptr.object.count() != 1) { + return error.InvalidAuthDocument; + } + const value = if (source == .stored_key) value: { + const secret_value = item.value_ptr.object.get("secret") orelse + return error.InvalidAuthDocument; + if (secret_value != .string or secret_value.string.len == 0) { + return error.InvalidAuthDocument; + } + break :value try alloc.dupe(u8, secret_value.string); + } else value: { + const session_value = item.value_ptr.object.get("session") orelse + return error.InvalidAuthDocument; + if (session_value != .object) return error.InvalidAuthDocument; + break :value try stringify_value(alloc, session_value); + }; + document.slots[@intFromEnum(source)] = value; + } + return document; + } + + pub fn stringify(self: *const Document, alloc: Allocator) ![]u8 { + var out: std.Io.Writer.Allocating = .init(alloc); + errdefer out.deinit(); + try out.writer.writeAll("{\"version\":2,\"credentials\":{"); + var wrote_entry = false; + for (std.meta.tags(StoredSource)) |source| { + const value = self.get(source) orelse continue; + try validate_entry(alloc, source, value); + if (wrote_entry) try out.writer.writeByte(','); + wrote_entry = true; + try std.json.Stringify.value(@tagName(source), .{}, &out.writer); + if (source == .stored_key) { + try out.writer.writeAll(":{\"secret\":"); + try std.json.Stringify.value(value, .{}, &out.writer); + try out.writer.writeByte('}'); + } else { + try out.writer.writeAll(":{\"session\":"); + try out.writer.writeAll(value); + try out.writer.writeByte('}'); + } + } + try out.writer.writeAll("}}\n"); + return out.toOwnedSlice(); + } + + fn transformed( + self: *const Document, + alloc: Allocator, + changed_source: StoredSource, + replacement: ?[]const u8, + ) !Document { + var next: Document = .{}; + errdefer next.deinit(alloc); + + for (std.meta.tags(StoredSource)) |source| { + const value = if (source == changed_source) replacement else self.get(source); + if (value) |bytes| next.slots[@intFromEnum(source)] = try alloc.dupe(u8, bytes); + } + return next; + } +}; + +fn validate_entry(alloc: Allocator, source: StoredSource, value: []const u8) !void { + if (value.len == 0) return error.InvalidAuthDocument; + if (source == .stored_key) return; + var parsed = std.json.parseFromSlice(std.json.Value, alloc, value, .{}) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.InvalidAuthDocument, + }; + defer parsed.deinit(); + if (parsed.value != .object) return error.InvalidAuthDocument; +} + +fn stringify_value(alloc: Allocator, value: std.json.Value) ![]u8 { + var out: std.Io.Writer.Allocating = .init(alloc); + errdefer out.deinit(); + std.json.Stringify.value(value, .{}, &out.writer) catch return error.OutOfMemory; + return out.toOwnedSlice() catch return error.OutOfMemory; +} + +fn check_parse_allocation_failures(alloc: Allocator) !void { + var document = try Document.parse( + alloc, + "{\"version\":2,\"credentials\":{\"stored_key\":{\"secret\":\"key\"},\"fx_login\":{\"session\":{\"version\":1}}}}", + ); + defer document.deinit(alloc); +} + +test "replacing one stored credential preserves the original and unrelated sources" { + const alloc = std.testing.allocator; + const empty: Document = .{}; + + var gateway = try empty.replaced(alloc, .stored_key, "gateway-secret"); + defer gateway.deinit(alloc); + var with_codex = try gateway.replaced(alloc, .chatgpt_subscription, "{\"version\":1}"); + defer with_codex.deinit(alloc); + var replaced_codex = try with_codex.replaced(alloc, .chatgpt_subscription, "{\"version\":2}"); + defer replaced_codex.deinit(alloc); + + try std.testing.expectEqualStrings("gateway-secret", gateway.get(.stored_key).?); + try std.testing.expect(gateway.get(.chatgpt_subscription) == null); + try std.testing.expectEqualStrings("gateway-secret", with_codex.get(.stored_key).?); + try std.testing.expectEqualStrings("{\"version\":1}", with_codex.get(.chatgpt_subscription).?); + try std.testing.expectEqualStrings("gateway-secret", replaced_codex.get(.stored_key).?); + try std.testing.expectEqualStrings("{\"version\":2}", replaced_codex.get(.chatgpt_subscription).?); +} + +test "removing one stored credential is idempotent and preserves unrelated sources" { + const alloc = std.testing.allocator; + const empty: Document = .{}; + + var gateway = try empty.replaced(alloc, .fx_login, "{\"version\":1}"); + defer gateway.deinit(alloc); + var with_grok = try gateway.replaced(alloc, .grok_subscription, "{\"version\":1}"); + defer with_grok.deinit(alloc); + var removed = try with_grok.removed(alloc, .grok_subscription); + defer removed.deinit(alloc); + var removed_again = try removed.removed(alloc, .grok_subscription); + defer removed_again.deinit(alloc); + + try std.testing.expectEqualStrings("{\"version\":1}", removed.get(.fx_login).?); + try std.testing.expect(removed.get(.grok_subscription) == null); + try std.testing.expect(removed.eql(removed_again)); +} + +test "version two auth document round trips every stored source" { + const alloc = std.testing.allocator; + var document: Document = .{}; + defer document.deinit(alloc); + + const fixtures = [_]struct { source: StoredSource, value: []const u8 }{ + .{ .source = .stored_key, .value = "gateway-secret" }, + .{ .source = .fx_login, .value = "{\"version\":1,\"access_token\":\"vercel\"}" }, + .{ .source = .chatgpt_subscription, .value = "{\"version\":1,\"access_token\":\"codex\"}" }, + .{ .source = .grok_subscription, .value = "{\"version\":1,\"access_token\":\"grok\"}" }, + }; + for (fixtures) |fixture| { + const next = try document.replaced(alloc, fixture.source, fixture.value); + document.deinit(alloc); + document = next; + } + + const encoded = try document.stringify(alloc); + defer secret.zeroAndFree(alloc, encoded); + var decoded = try Document.parse(alloc, encoded); + defer decoded.deinit(alloc); + + try std.testing.expect(document.eql(decoded)); +} + +test "stored session replacement canonicalizes insignificant JSON whitespace" { + const alloc = std.testing.allocator; + const empty: Document = .{}; + var document = try empty.replaced( + alloc, + .fx_login, + "{\"version\":1,\"access_token\":\"token\"}\n", + ); + defer document.deinit(alloc); + try std.testing.expectEqualStrings( + "{\"version\":1,\"access_token\":\"token\"}", + document.get(.fx_login).?, + ); +} + +test "auth document rejects malformed or unknown version two state" { + const alloc = std.testing.allocator; + const invalid = [_][]const u8{ + "{}", + "{\"version\":1,\"credentials\":{}}", + "{\"version\":2,\"credentials\":[]}", + "{\"version\":2,\"credentials\":{\"stored_key\":{\"secret\":\"\"}}}", + "{\"version\":2,\"credentials\":{\"fx_login\":\"not-an-object\"}}", + "{\"version\":2,\"credentials\":{\"unknown\":{}}}", + }; + for (invalid) |bytes| { + try std.testing.expectError(error.InvalidAuthDocument, Document.parse(alloc, bytes)); + } +} + +test "auth document parse cleans up allocation failures" { + try std.testing.checkAllAllocationFailures( + std.testing.allocator, + check_parse_allocation_failures, + .{}, + ); +} + +test "load decision keeps inspection pure and migrates only active legacy state" { + const cases = [_]struct { + state: StoreState, + intent: LoadIntent, + expected: LoadDecision, + }{ + .{ .state = .empty, .intent = .inspect, .expected = .missing }, + .{ .state = .empty, .intent = .active, .expected = .missing }, + .{ .state = .legacy, .intent = .inspect, .expected = .use_legacy }, + .{ .state = .legacy, .intent = .active, .expected = .migrate_legacy }, + .{ .state = .current, .intent = .inspect, .expected = .use_current }, + .{ .state = .current, .intent = .active, .expected = .use_current }, + .{ .state = .malformed_current, .intent = .inspect, .expected = .reject_current }, + .{ .state = .malformed_current, .intent = .active, .expected = .reject_current }, + }; + for (cases) |case| { + try std.testing.expectEqual(case.expected, decide_load(case.state, case.intent)); + } +} + +test "auth document parser handles arbitrary bytes" { + try std.testing.fuzz({}, fuzz_auth_document, .{ + .corpus = &.{ + "", + "{}", + "{\"version\":2,\"credentials\":{}}", + "{\"version\":2,\"credentials\":{\"stored_key\":{\"secret\":\"key\"}}}", + }, + }); +} + +fn fuzz_auth_document(_: void, smith: *std.testing.Smith) !void { + var buffer: [4096]u8 = undefined; + const len: usize = @intCast(smith.slice(&buffer)); + var document = Document.parse(std.testing.allocator, buffer[0..len]) catch return; + defer document.deinit(std.testing.allocator); + + const encoded = try document.stringify(std.testing.allocator); + defer secret.zeroAndFree(std.testing.allocator, encoded); + var reparsed = try Document.parse(std.testing.allocator, encoded); + defer reparsed.deinit(std.testing.allocator); + try std.testing.expect(document.eql(reparsed)); +} diff --git a/src/core/auth/chatgpt_oauth.zig b/src/core/auth/chatgpt_oauth.zig index f7d3594a3..5d0c3f721 100644 --- a/src/core/auth/chatgpt_oauth.zig +++ b/src/core/auth/chatgpt_oauth.zig @@ -383,7 +383,7 @@ pub fn logout() !chatgpt_session.DeleteOutcome { } pub fn sourceExists(alloc: Allocator) !bool { - var session = (try chatgpt_session.load(alloc)) orelse return false; + var session = (try chatgpt_session.loadStored(alloc)) orelse return false; defer session.deinit(alloc); return true; } @@ -394,7 +394,7 @@ pub fn loadAccess( mode: RefreshMode, ) !?Access { if (mode == .stored) { - var session = (try chatgpt_session.load(alloc)) orelse return null; + var session = (try chatgpt_session.loadStored(alloc)) orelse return null; defer session.deinit(alloc); return takeAccess(&session); } @@ -439,13 +439,15 @@ fn refreshSession( defer token.deinit(alloc); const account_id = try extractAccountId(alloc, token.access_token); - errdefer alloc.free(account_id); + var account_id_owned = true; + errdefer if (account_id_owned) alloc.free(account_id); if (!std.mem.eql(u8, account_id, session.account_id)) { return error.ChatGptAccountChanged; } const refresh_token = if (token.refresh_token) |rotated| rotated else try alloc.dupe(u8, session.refresh_token); + var refresh_token_owned = true; + errdefer if (refresh_token_owned) secret.zeroAndFree(alloc, refresh_token); if (token.refresh_token != null) token.refresh_token = null; - errdefer secret.zeroAndFree(alloc, refresh_token); const expires_at_ms = if (token.expires_in) |expires_in| blk: { const duration_ms = std.math.mul(i64, expires_in, std.time.ms_per_s) catch return error.InvalidChatGptOAuthResponse; @@ -459,6 +461,8 @@ fn refreshSession( .account_id = account_id, }; token.access_token = &.{}; + account_id_owned = false; + refresh_token_owned = false; errdefer replacement.deinit(alloc); try mutation.save(alloc, replacement); diff --git a/src/core/auth/chatgpt_session.zig b/src/core/auth/chatgpt_session.zig index 6c9ed7249..8a6ff56dc 100644 --- a/src/core/auth/chatgpt_session.zig +++ b/src/core/auth/chatgpt_session.zig @@ -1,19 +1,14 @@ const std = @import("std"); const debug_trace = @import("../shared/debug_trace.zig"); const host_target = @import("../hosts/target.zig"); -const io_mod = @import("../shared/io.zig"); -const profile_paths = @import("../shared/profile_paths.zig"); +const native_auth_store = if (host_target.is_wasm) struct {} else @import("../hosts/native_auth_store.zig"); const secret = @import("secret.zig"); const Allocator = std.mem.Allocator; const schema_version: i64 = 1; -const max_auth_file_bytes: usize = 64 * 1024; const expiry_skew_ms: i64 = 60 * 1000; -const mutation_lock_file_name = "chatgpt-auth.lock"; -const mutation_lock_deadline_ms: u64 = 2000; pub const issuer = "https://auth.openai.com"; -pub const auth_file_name = profile_paths.chatgpt_auth_file_name; pub fn refreshDeadlineMs(expires_at_ms: i64) i64 { return @max(expires_at_ms - expiry_skew_ms, 0); @@ -43,82 +38,79 @@ pub const DeleteOutcome = enum { deleted_not_durable, }; -pub const Mutation = struct { - fx_dir: io_mod.VerifiedDir, - lock: io_mod.TimedAdvisoryLock, +pub const Mutation = if (host_target.is_wasm) WasmMutation else NativeMutation; + +const WasmMutation = struct { + pub fn deinit(self: *WasmMutation) void { + self.* = undefined; + } + + pub fn load(_: *WasmMutation, _: Allocator) !?Session { + return null; + } + + pub fn save(_: *WasmMutation, _: Allocator, _: Session) !void { + return error.ChatGptOAuthUnavailable; + } + + pub fn delete(_: *WasmMutation) !DeleteOutcome { + return .missing; + } +}; + +const NativeMutation = struct { + inner: native_auth_store.EntryMutation, pub fn deinit(self: *Mutation) void { - self.lock.release(); - self.fx_dir.close(); + self.inner.deinit(); self.* = undefined; } pub fn load(self: *Mutation, alloc: Allocator) !?Session { - return loadFromDir(alloc, &self.fx_dir.dir, true); + const bytes = (try self.inner.load(alloc)) orelse return null; + defer secret.zeroAndFree(alloc, bytes); + return parse(alloc, bytes) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + debug_trace.logf("auth", "ChatGPT session load failed step=parse_common_mutation err={s}", .{@errorName(err)}); + return null; + }, + }; } pub fn save(self: *Mutation, alloc: Allocator, session: Session) !void { const text = try stringify(alloc, session); defer secret.zeroAndFree(alloc, text); - try io_mod.durableReplaceVerified(alloc, &self.fx_dir, auth_file_name, text); + try self.inner.save(alloc, text); } pub fn delete(self: *Mutation) !DeleteOutcome { - self.fx_dir.dir.deleteFile(io_mod.getIo(), auth_file_name) catch |err| switch (err) { - error.FileNotFound => return .missing, - else => return err, + return switch (try self.inner.delete(std.heap.c_allocator)) { + .deleted => .deleted, + .missing => .missing, + .deleted_not_durable => .deleted_not_durable, }; - const durable: io_mod.DurableOps = .{}; - durable.sync_dir(durable.ctx, self.fx_dir.dir) catch return .deleted_not_durable; - return .deleted; } }; pub fn load(alloc: Allocator) !?Session { if (comptime host_target.is_wasm) return null; - const home = io_mod.getenv("HOME") orelse return null; - var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| { - debug_trace.logf("auth", "ChatGPT session load failed step=open_home err={s}", .{@errorName(err)}); - return null; - }; - defer home_dir.close(io_mod.getIo()); + return loadNative(alloc, .active); +} - var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }) catch |err| { - if (err != error.FileNotFound) { - debug_trace.logf("auth", "ChatGPT session load failed step=open_profile err={s}", .{@errorName(err)}); - } - return null; - }; - defer fx_dir.close(io_mod.getIo()); - return loadFromDir(alloc, &fx_dir, false); +pub fn loadStored(alloc: Allocator) !?Session { + if (comptime host_target.is_wasm) return null; + return loadNative(alloc, .inspect); } -fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir, report_open_failure: bool) !?Session { - var file = fx_dir.openFile(io_mod.getIo(), auth_file_name, .{ - .mode = .read_only, - .allow_directory = false, - .follow_symlinks = false, - .resolve_beneath = true, - }) catch |err| switch (err) { - error.FileNotFound => return null, +fn loadNative(alloc: Allocator, intent: @import("auth_store.zig").LoadIntent) !?Session { + const bytes = (native_auth_store.load_entry(alloc, .chatgpt_subscription, intent) catch |err| switch (err) { + error.OutOfMemory => return err, else => { - debug_trace.logf("auth", "ChatGPT session load failed step=open_file err={s}", .{@errorName(err)}); - if (report_open_failure) return err; + debug_trace.logf("auth", "ChatGPT session load failed step=common_store err={s}", .{@errorName(err)}); return null; }, - }; - defer file.close(io_mod.getIo()); - - const stat = try file.stat(io_mod.getIo()); - if (stat.kind != .file or stat.permissions.toMode() & 0o077 != 0) { - debug_trace.logf("auth", "ChatGPT session load failed step=permissions err=InsecureAuthFile", .{}); - return null; - } - - const bytes = try io_mod.readFileToEnd(alloc, &file, max_auth_file_bytes); + }) orelse return null; defer secret.zeroAndFree(alloc, bytes); return parse(alloc, bytes) catch |err| switch (err) { error.OutOfMemory => return err, @@ -131,67 +123,14 @@ fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir, report_open_failure: bool) pub fn saveNewSession(alloc: Allocator, session: Session) !void { if (comptime host_target.is_wasm) return error.ChatGptOAuthUnavailable; - var mutation = try beginMutation(); + var mutation = try beginExistingMutation() orelse return error.HomeNotSet; defer mutation.deinit(); try mutation.save(alloc, session); } pub fn beginExistingMutation() !?Mutation { if (comptime host_target.is_wasm) return null; - const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; - var home_dir = io_mod.VerifiedDir{ - .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), - }; - defer home_dir.close(); - - const fx_dir = openExistingPrivateFxDir(&home_dir) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; - return try lockMutation(fx_dir); -} - -fn beginMutation() !Mutation { - const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; - var home_dir = io_mod.VerifiedDir{ - .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), - }; - defer home_dir.close(); - - const fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&home_dir, profile_paths.root_dir_name); - return lockMutation(fx_dir); -} - -fn lockMutation(open_fx_dir: io_mod.VerifiedDir) !Mutation { - var fx_dir = open_fx_dir; - errdefer fx_dir.close(); - var lock = try io_mod.acquireTimedAdvisoryLock( - &fx_dir, - mutation_lock_file_name, - mutation_lock_deadline_ms, - ); - errdefer lock.release(); - return .{ .fx_dir = fx_dir, .lock = lock }; -} - -fn openExistingPrivateFxDir(home_dir: *io_mod.VerifiedDir) !io_mod.VerifiedDir { - var dir = try home_dir.dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }); - errdefer dir.close(io_mod.getIo()); - - const initial_stat = try dir.stat(io_mod.getIo()); - if (initial_stat.kind != .directory) return error.DurablePathUnsafe; - if (initial_stat.permissions.toMode() & 0o200 == 0) return error.PrivateStatePermissionsUnsupported; - dir.setPermissions(io_mod.getIo(), std.Io.File.Permissions.fromMode(0o700)) catch { - return error.PrivateStatePermissionsUnsupported; - }; - const stat = try dir.stat(io_mod.getIo()); - if (stat.kind != .directory or stat.permissions.toMode() & 0o777 != 0o700) { - return error.PrivateStatePermissionsUnsupported; - } - return .{ .dir = dir }; + return .{ .inner = try native_auth_store.begin_entry_mutation(.chatgpt_subscription) }; } pub fn parse(alloc: Allocator, bytes: []const u8) !Session { diff --git a/src/core/auth/credentials.zig b/src/core/auth/credentials.zig index 563a11c04..33ee26442 100644 --- a/src/core/auth/credentials.zig +++ b/src/core/auth/credentials.zig @@ -350,7 +350,7 @@ pub fn resolvePreferring( if (secret_store.isDisabled()) return .{ .fx_login_status = fx_login_status }; var status: StoredKeyReadStatus = .not_found; - const stored = loadSource(alloc, transport, secret_store, .stored_key) catch |err| blk: { + const stored = loadStoredKeyCredential(alloc, secret_store, mode) catch |err| blk: { if (err == error.OutOfMemory) return err; status = .unavailable; debug_trace.logf("auth", "stored key load failed err={s} status={t}", .{ @errorName(err), status }); @@ -394,6 +394,7 @@ fn loadPreferredSource( .stored => loadStoredGrokCredential(alloc), .refresh_if_needed => loadGrokCredential(alloc, transport, .if_needed), }, + .stored_key => loadStoredKeyCredential(alloc, secret_store, mode), else => loadSource(alloc, transport, secret_store, source), }; } @@ -408,7 +409,7 @@ pub fn loadSource( .vercel_oidc_token => loadEnvCredential(alloc, "VERCEL_OIDC_TOKEN", source), .ai_gateway_api_key => loadEnvCredential(alloc, "AI_GATEWAY_API_KEY", source), .fx_login => loadFxLoginCredential(alloc, transport), - .stored_key => loadStoredKeyCredential(alloc, secret_store), + .stored_key => loadStoredKeyCredential(alloc, secret_store, .refresh_if_needed), .chatgpt_subscription => loadChatGptCredential(alloc, transport, .if_needed), .grok_subscription => loadGrokCredential(alloc, transport, .if_needed), }; @@ -423,7 +424,7 @@ pub fn sourceExists( .vercel_oidc_token => nonEmptyEnvValue("VERCEL_OIDC_TOKEN") != null, .ai_gateway_api_key => nonEmptyEnvValue("AI_GATEWAY_API_KEY") != null, .fx_login => blk: { - const loaded = oauth_session.load(alloc) catch |err| switch (err) { + const loaded = oauth_session.loadStored(alloc) catch |err| switch (err) { error.OutOfMemory => return err, else => { debug_trace.logf("auth", "source probe failed source=fx_login err={s}", .{@errorName(err)}); @@ -438,7 +439,7 @@ pub fn sourceExists( .grok_subscription => grok_oauth.sourceExists(alloc), .stored_key => blk: { if (secret_store.isDisabled()) break :blk false; - const stored = secret_store.load(alloc) catch |err| switch (err) { + const stored = secret_store.loadStored(alloc) catch |err| switch (err) { error.OutOfMemory => return err, else => { debug_trace.logf("auth", "source probe failed source=stored_key err={s}", .{@errorName(err)}); @@ -467,9 +468,13 @@ fn loadEnvCredential( fn loadStoredKeyCredential( alloc: std.mem.Allocator, secret_store: host.SecretStore, + mode: LoadMode, ) !?Credential { if (secret_store.isDisabled()) return null; - const value = (try secret_store.load(alloc)) orelse return null; + const value = (try if (mode == .stored) + secret_store.loadStored(alloc) + else + secret_store.load(alloc)) orelse return null; return .{ .token = value, .source = .stored_key }; } @@ -544,7 +549,7 @@ pub fn loadFxLoginCredential( } fn loadStoredFxLoginCredential(alloc: std.mem.Allocator) !?Credential { - var session = (try oauth_session.load(alloc)) orelse return null; + var session = (try oauth_session.loadStored(alloc)) orelse return null; defer session.deinit(alloc); return takeCredentialFromSession(&session, null); } @@ -855,6 +860,7 @@ const SecretStoreFixture = struct { disabled: bool = false, unreadable: bool = false, load_calls: usize = 0, + stored_load_calls: usize = 0, fn provider(self: *@This()) host.SecretStore { return .{ @@ -862,6 +868,7 @@ const SecretStoreFixture = struct { .backend_label = "test credential store", .is_disabled_fn = isDisabled, .load_fn = load, + .load_stored_fn = loadStored, .store_fn = store, .store_interactive_fn = storeInteractive, }; @@ -878,6 +885,19 @@ const SecretStoreFixture = struct { ) host.SecretStoreLoadError!?[]u8 { const self: *@This() = @ptrCast(@alignCast(raw_context.?)); self.load_calls += 1; + return self.loadValue(alloc); + } + + fn loadStored( + raw_context: ?*anyopaque, + alloc: std.mem.Allocator, + ) host.SecretStoreLoadError!?[]u8 { + const self: *@This() = @ptrCast(@alignCast(raw_context.?)); + self.stored_load_calls += 1; + return self.loadValue(alloc); + } + + fn loadValue(self: *@This(), alloc: std.mem.Allocator) host.SecretStoreLoadError!?[]u8 { if (self.unreadable) return error.StoredKeyUnreadable; const value = self.value orelse return null; return try alloc.dupe(u8, value); @@ -1003,6 +1023,7 @@ test "a disabled stored key is reported as never attempted, not as absent" { try std.testing.expectEqual(StoredKeyReadStatus.not_attempted, resolution.stored_key_status); } try std.testing.expectEqual(@as(usize, 0), store_fixture.load_calls); + try std.testing.expectEqual(@as(usize, 0), store_fixture.stored_load_calls); } test "credential resolution loads a stored key only through the injected host port" { @@ -1019,7 +1040,8 @@ test "credential resolution loads a stored key only through the injected host po ); defer if (resolution.credential) |*credential| credential.deinit(alloc); - try std.testing.expectEqual(@as(usize, 1), store_fixture.load_calls); + try std.testing.expectEqual(@as(usize, 0), store_fixture.load_calls); + try std.testing.expectEqual(@as(usize, 1), store_fixture.stored_load_calls); try std.testing.expectEqual(StoredKeyReadStatus.not_attempted, resolution.stored_key_status); try std.testing.expectEqual(Source.stored_key, resolution.credential.?.source); try std.testing.expectEqualStrings("injected-test-value", resolution.credential.?.token); @@ -1038,7 +1060,8 @@ test "credential resolution preserves unreadable store classification" { .stored, ); - try std.testing.expectEqual(@as(usize, 1), store_fixture.load_calls); + try std.testing.expectEqual(@as(usize, 0), store_fixture.load_calls); + try std.testing.expectEqual(@as(usize, 1), store_fixture.stored_load_calls); try std.testing.expect(resolution.credential == null); try std.testing.expectEqual(StoredKeyReadStatus.unavailable, resolution.stored_key_status); } @@ -1063,6 +1086,7 @@ test "a failed fx-login refresh falls through to the stored key" { try std.testing.expectEqualStrings("stored-key-that-works", credential.token); try std.testing.expectEqual(FxLoginReadStatus.unavailable, resolution.fx_login_status); try std.testing.expectEqual(@as(usize, 1), store_fixture.load_calls); + try std.testing.expectEqual(@as(usize, 0), store_fixture.stored_load_calls); } test "a failed fx-login refresh is still reported when nothing else resolves" { diff --git a/src/core/auth/grok_oauth.zig b/src/core/auth/grok_oauth.zig index 699980425..37c8633ef 100644 --- a/src/core/auth/grok_oauth.zig +++ b/src/core/auth/grok_oauth.zig @@ -484,7 +484,7 @@ pub fn logout(alloc: Allocator, transport: oauth_transport.Provider) !LogoutResu } pub fn sourceExists(alloc: Allocator) !bool { - var session = (try grok_session.load(alloc)) orelse return false; + var session = (try grok_session.loadStored(alloc)) orelse return false; defer session.deinit(alloc); return true; } @@ -495,7 +495,7 @@ pub fn loadAccess( mode: RefreshMode, ) !?Access { if (mode == .stored) { - var session = (try grok_session.load(alloc)) orelse return null; + var session = (try grok_session.loadStored(alloc)) orelse return null; defer session.deinit(alloc); return takeAccess(&session); } @@ -539,13 +539,15 @@ fn refreshSession( defer token.deinit(alloc); const account_id = try fetchAccountId(alloc, transport, token.access_token); - errdefer alloc.free(account_id); + var account_id_owned = true; + errdefer if (account_id_owned) alloc.free(account_id); if (!std.mem.eql(u8, account_id, session.account_id)) { return error.GrokAccountChanged; } const refresh_token = if (token.refresh_token) |rotated| rotated else try alloc.dupe(u8, session.refresh_token); + var refresh_token_owned = true; + errdefer if (refresh_token_owned) secret.zeroAndFree(alloc, refresh_token); if (token.refresh_token != null) token.refresh_token = null; - errdefer secret.zeroAndFree(alloc, refresh_token); const expires_at_ms = if (token.expires_in) |expires_in| blk: { const duration_ms = std.math.mul(i64, expires_in, std.time.ms_per_s) catch return error.InvalidGrokOAuthResponse; @@ -559,6 +561,8 @@ fn refreshSession( .account_id = account_id, }; token.access_token = &.{}; + account_id_owned = false; + refresh_token_owned = false; errdefer replacement.deinit(alloc); try mutation.save(alloc, replacement); diff --git a/src/core/auth/grok_session.zig b/src/core/auth/grok_session.zig index ac362338b..648689b2b 100644 --- a/src/core/auth/grok_session.zig +++ b/src/core/auth/grok_session.zig @@ -1,20 +1,14 @@ const std = @import("std"); const debug_trace = @import("../shared/debug_trace.zig"); const host_target = @import("../hosts/target.zig"); -const io_mod = @import("../shared/io.zig"); -const profile_paths = @import("../shared/profile_paths.zig"); +const native_auth_store = if (host_target.is_wasm) struct {} else @import("../hosts/native_auth_store.zig"); const secret = @import("secret.zig"); const Allocator = std.mem.Allocator; const schema_version: i64 = 1; -const max_auth_file_bytes: usize = 64 * 1024; const expiry_skew_ms: i64 = 60 * 1000; -const mutation_lock_file_name = "grok-auth.lock"; -const mutation_lock_deadline_ms: u64 = 2000; const max_account_id_bytes: usize = 1024; -const auth_file_name = profile_paths.grok_auth_file_name; - pub fn refreshDeadlineMs(expires_at_ms: i64) i64 { return @max(expires_at_ms - expiry_skew_ms, 0); } @@ -51,82 +45,79 @@ pub const DeleteOutcome = enum { deleted_not_durable, }; -pub const Mutation = struct { - fx_dir: io_mod.VerifiedDir, - lock: io_mod.TimedAdvisoryLock, +pub const Mutation = if (host_target.is_wasm) WasmMutation else NativeMutation; + +const WasmMutation = struct { + pub fn deinit(self: *WasmMutation) void { + self.* = undefined; + } + + pub fn load(_: *WasmMutation, _: Allocator) !?Session { + return null; + } + + pub fn save(_: *WasmMutation, _: Allocator, _: Session) !void { + return error.GrokOAuthUnavailable; + } + + pub fn delete(_: *WasmMutation) !DeleteOutcome { + return .missing; + } +}; + +const NativeMutation = struct { + inner: native_auth_store.EntryMutation, pub fn deinit(self: *Mutation) void { - self.lock.release(); - self.fx_dir.close(); + self.inner.deinit(); self.* = undefined; } pub fn load(self: *Mutation, alloc: Allocator) !?Session { - return loadFromDir(alloc, &self.fx_dir.dir, true); + const bytes = (try self.inner.load(alloc)) orelse return null; + defer secret.zeroAndFree(alloc, bytes); + return parse(alloc, bytes) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + debug_trace.logf("auth", "Grok session load failed step=parse_common_mutation err={s}", .{@errorName(err)}); + return null; + }, + }; } pub fn save(self: *Mutation, alloc: Allocator, session: Session) !void { const text = try stringify(alloc, session); defer secret.zeroAndFree(alloc, text); - try io_mod.durableReplaceVerified(alloc, &self.fx_dir, auth_file_name, text); + try self.inner.save(alloc, text); } pub fn delete(self: *Mutation) !DeleteOutcome { - self.fx_dir.dir.deleteFile(io_mod.getIo(), auth_file_name) catch |err| switch (err) { - error.FileNotFound => return .missing, - else => return err, + return switch (try self.inner.delete(std.heap.c_allocator)) { + .deleted => .deleted, + .missing => .missing, + .deleted_not_durable => .deleted_not_durable, }; - const durable: io_mod.DurableOps = .{}; - durable.sync_dir(durable.ctx, self.fx_dir.dir) catch return .deleted_not_durable; - return .deleted; } }; pub fn load(alloc: Allocator) !?Session { if (comptime host_target.is_wasm) return null; - const home = io_mod.getenv("HOME") orelse return null; - var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| { - debug_trace.logf("auth", "Grok session load failed step=open_home err={s}", .{@errorName(err)}); - return null; - }; - defer home_dir.close(io_mod.getIo()); - - var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }) catch |err| { - if (err != error.FileNotFound) { - debug_trace.logf("auth", "Grok session load failed step=open_profile err={s}", .{@errorName(err)}); - } - return null; - }; - defer fx_dir.close(io_mod.getIo()); - return loadFromDir(alloc, &fx_dir, false); + return loadNative(alloc, .active); +} + +pub fn loadStored(alloc: Allocator) !?Session { + if (comptime host_target.is_wasm) return null; + return loadNative(alloc, .inspect); } -fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir, report_open_failure: bool) !?Session { - var file = fx_dir.openFile(io_mod.getIo(), auth_file_name, .{ - .mode = .read_only, - .allow_directory = false, - .follow_symlinks = false, - .resolve_beneath = true, - }) catch |err| switch (err) { - error.FileNotFound => return null, +fn loadNative(alloc: Allocator, intent: @import("auth_store.zig").LoadIntent) !?Session { + const bytes = (native_auth_store.load_entry(alloc, .grok_subscription, intent) catch |err| switch (err) { + error.OutOfMemory => return err, else => { - debug_trace.logf("auth", "Grok session load failed step=open_file err={s}", .{@errorName(err)}); - if (report_open_failure) return err; + debug_trace.logf("auth", "Grok session load failed step=common_store err={s}", .{@errorName(err)}); return null; }, - }; - defer file.close(io_mod.getIo()); - - const stat = try file.stat(io_mod.getIo()); - if (stat.kind != .file or stat.permissions.toMode() & 0o077 != 0) { - debug_trace.logf("auth", "Grok session load failed step=permissions err=InsecureAuthFile", .{}); - return null; - } - - const bytes = try io_mod.readFileToEnd(alloc, &file, max_auth_file_bytes); + }) orelse return null; defer secret.zeroAndFree(alloc, bytes); return parse(alloc, bytes) catch |err| switch (err) { error.OutOfMemory => return err, @@ -139,67 +130,14 @@ fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir, report_open_failure: bool) pub fn saveNewSession(alloc: Allocator, session: Session) !void { if (comptime host_target.is_wasm) return error.GrokOAuthUnavailable; - var mutation = try beginMutation(); + var mutation = try beginExistingMutation() orelse return error.HomeNotSet; defer mutation.deinit(); try mutation.save(alloc, session); } pub fn beginExistingMutation() !?Mutation { if (comptime host_target.is_wasm) return null; - const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; - var home_dir = io_mod.VerifiedDir{ - .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), - }; - defer home_dir.close(); - - const fx_dir = openExistingPrivateFxDir(&home_dir) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; - return try lockMutation(fx_dir); -} - -fn beginMutation() !Mutation { - const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; - var home_dir = io_mod.VerifiedDir{ - .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), - }; - defer home_dir.close(); - - const fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&home_dir, profile_paths.root_dir_name); - return lockMutation(fx_dir); -} - -fn lockMutation(open_fx_dir: io_mod.VerifiedDir) !Mutation { - var fx_dir = open_fx_dir; - errdefer fx_dir.close(); - var lock = try io_mod.acquireTimedAdvisoryLock( - &fx_dir, - mutation_lock_file_name, - mutation_lock_deadline_ms, - ); - errdefer lock.release(); - return .{ .fx_dir = fx_dir, .lock = lock }; -} - -fn openExistingPrivateFxDir(home_dir: *io_mod.VerifiedDir) !io_mod.VerifiedDir { - var dir = try home_dir.dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }); - errdefer dir.close(io_mod.getIo()); - - const initial_stat = try dir.stat(io_mod.getIo()); - if (initial_stat.kind != .directory) return error.DurablePathUnsafe; - if (initial_stat.permissions.toMode() & 0o200 == 0) return error.PrivateStatePermissionsUnsupported; - dir.setPermissions(io_mod.getIo(), std.Io.File.Permissions.fromMode(0o700)) catch { - return error.PrivateStatePermissionsUnsupported; - }; - const stat = try dir.stat(io_mod.getIo()); - if (stat.kind != .directory or stat.permissions.toMode() & 0o777 != 0o700) { - return error.PrivateStatePermissionsUnsupported; - } - return .{ .dir = dir }; + return .{ .inner = try native_auth_store.begin_entry_mutation(.grok_subscription) }; } pub fn parse(alloc: Allocator, bytes: []const u8) !Session { diff --git a/src/core/auth/oauth_session.zig b/src/core/auth/oauth_session.zig index cc052877c..beda311e0 100644 --- a/src/core/auth/oauth_session.zig +++ b/src/core/auth/oauth_session.zig @@ -1,10 +1,8 @@ const std = @import("std"); -const builtin = @import("builtin"); const debug_trace = @import("../shared/debug_trace.zig"); const host_target = @import("../hosts/target.zig"); -const native_keychain = @import("../hosts/native_keychain.zig"); +const native_auth_store = if (host_target.is_wasm) struct {} else @import("../hosts/native_auth_store.zig"); const io_mod = @import("../shared/io.zig"); -const profile_paths = @import("../shared/profile_paths.zig"); const js_host_auth = @import("js_host_auth.zig"); const secret = @import("secret.zig"); @@ -14,130 +12,13 @@ pub const issuer = "https://vercel.com"; pub const client_id_env = "FX_OAUTH_CLIENT_ID"; pub const default_client_id = "cl_zzh5hiOZbwJ9bfqEcYqPIJv3TaPaEYL0"; const e2e_issuer_url_env = "FX_E2E_OAUTH_ISSUER_URL"; -pub const auth_file_name = profile_paths.auth_file_name; const schema_version: i64 = 1; -const max_auth_file_bytes: usize = 64 * 1024; const expiry_skew_ms: i64 = 60 * 1000; -const mutation_lock_file_name = "auth.lock"; -const mutation_lock_deadline_ms: u64 = 2000; -const e2e_lock_contention_file_name = "auth-lock-contention"; -const LoadMode = enum { tolerate_open_failure, report_open_failure }; - -const StorageBackend = enum { - profile_file, - macos_keychain, -}; - -const FileState = enum { absent, valid, unusable }; -const KeychainState = enum { absent, valid, invalid, unavailable }; -const Resolution = enum { - missing, - file_migrate, - file_defer_migration, - keychain, - storage_error, -}; - -const Authority = enum { unresolved, missing, profile_file, keychain }; - -const KeychainError = Allocator.Error || native_keychain.Error; - -const KeychainBackend = struct { - context: ?*anyopaque = null, - load_fn: *const fn (?*anyopaque, Allocator) KeychainError!?[]u8, - store_fn: *const fn (?*anyopaque, []const u8) KeychainError!void, - delete_fn: *const fn (?*anyopaque, Allocator) KeychainError!bool, - - fn load(self: KeychainBackend, alloc: Allocator) KeychainError!?[]u8 { - return self.load_fn(self.context, alloc); - } - - fn store(self: KeychainBackend, value: []const u8) KeychainError!void { - return self.store_fn(self.context, value); - } - - fn delete(self: KeychainBackend, alloc: Allocator) KeychainError!bool { - return self.delete_fn(self.context, alloc); - } -}; - -const native_keychain_backend: KeychainBackend = .{ - .load_fn = nativeKeychainLoad, - .store_fn = nativeKeychainStore, - .delete_fn = nativeKeychainDelete, -}; - -fn nativeKeychainLoad(_: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { - return native_keychain.loadOAuthSession(alloc); -} - -fn nativeKeychainStore(_: ?*anyopaque, value: []const u8) KeychainError!void { - return native_keychain.storeOAuthSession(value); -} - -fn nativeKeychainDelete(_: ?*anyopaque, alloc: Allocator) KeychainError!bool { - return native_keychain.deleteOAuthSession(alloc); -} - -fn selectStorageBackend(os_tag: std.Target.Os.Tag, keychain_disabled: bool) StorageBackend { - if (os_tag == .macos and !keychain_disabled) return .macos_keychain; - return .profile_file; -} - -fn storageBackend() StorageBackend { - // A temporary HOME does not isolate the host account's macOS Keychain. - // Keychain-specific tests inject their backend explicitly. - if (comptime builtin.is_test) return .profile_file; - return selectStorageBackend(builtin.os.tag, native_keychain.isDisabled()); -} - -fn selectResolution(file: FileState, keychain: KeychainState) Resolution { - return switch (file) { - .valid => if (keychain == .unavailable) .file_defer_migration else .file_migrate, - .absent, .unusable => switch (keychain) { - .absent => .missing, - .valid => .keychain, - .invalid, .unavailable => .storage_error, - }, - }; -} pub fn refresh_deadline_ms(expires_at_ms: i64) i64 { return expires_at_ms -| expiry_skew_ms; } -const MutationLockProbe = struct { - fx_dir: std.Io.Dir, - signaled: bool = false, - - fn tryLock(raw_ctx: ?*anyopaque, file: std.Io.File) anyerror!bool { - const locked = try file.tryLock(io_mod.getIo(), .exclusive); - const self: *MutationLockProbe = @ptrCast(@alignCast(raw_ctx.?)); - if (!locked and !self.signaled) { - signalE2ELockContention(self.fx_dir); - self.signaled = true; - } - return locked; - } -}; - -fn signalE2ELockContention(fx_dir: std.Io.Dir) void { - const enabled = io_mod.getenv("FX_E2E_AUTH_LOCK_CONTENTION") orelse return; - if (!std.mem.eql(u8, enabled, "1")) return; - var file = fx_dir.createFile(io_mod.getIo(), e2e_lock_contention_file_name, .{ - .truncate = true, - .permissions = std.Io.File.Permissions.fromMode(0o600), - }) catch return; - defer file.close(io_mod.getIo()); - file.writeStreamingAll(io_mod.getIo(), "contended\n") catch {}; -} - -const DeleteOutcome = enum { - deleted, - missing, - deleted_not_durable, -}; - pub const DeleteResult = struct { session_deleted: bool = false, local_cleanup_failed: bool = false, @@ -171,295 +52,42 @@ pub const Session = struct { } }; -const FileObservation = union(enum) { - absent, - valid: Session, - unusable, +pub const Mutation = if (host_target.is_wasm) HostMutation else CommonNativeMutation; - fn state(self: FileObservation) FileState { - return switch (self) { - .absent => .absent, - .valid => .valid, - .unusable => .unusable, - }; - } - - fn takeSession(self: *FileObservation) ?Session { - return switch (self.*) { - .valid => |session| blk: { - self.* = .absent; - break :blk session; - }, - else => null, - }; - } - - fn deinit(self: *FileObservation, alloc: Allocator) void { - switch (self.*) { - .valid => |*session| session.deinit(alloc), - else => {}, - } - self.* = .absent; - } -}; - -const KeychainObservation = union(enum) { - absent, - valid: Session, - invalid, - unavailable: KeychainError, +const CommonNativeMutation = struct { + inner: native_auth_store.EntryMutation, - fn state(self: KeychainObservation) KeychainState { - return switch (self) { - .absent => .absent, - .valid => .valid, - .invalid => .invalid, - .unavailable => .unavailable, - }; + pub fn deinit(self: *CommonNativeMutation) void { + self.inner.deinit(); + self.* = undefined; } - fn takeSession(self: *KeychainObservation) ?Session { - return switch (self.*) { - .valid => |session| blk: { - self.* = .absent; - break :blk session; + pub fn load(self: *CommonNativeMutation, alloc: Allocator) !?Session { + const bytes = (try self.inner.load(alloc)) orelse return null; + defer secret.zeroAndFree(alloc, bytes); + return parse(alloc, bytes) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + debug_trace.logf("auth", "session load failed step=parse_common_mutation err={s}", .{@errorName(err)}); + return null; }, - else => null, }; } - fn storageError(self: KeychainObservation) (KeychainError || error{ - InvalidOAuthKeychainSession, - InvalidOAuthStorageState, - }) { - return switch (self) { - .invalid => error.InvalidOAuthKeychainSession, - .unavailable => |err| err, - else => error.InvalidOAuthStorageState, - }; - } - - fn deinit(self: *KeychainObservation, alloc: Allocator) void { - switch (self.*) { - .valid => |*session| session.deinit(alloc), - else => {}, - } - self.* = .absent; - } -}; - -fn observeAuthFile(alloc: Allocator, fx_dir: *std.Io.Dir) !FileObservation { - var file = fx_dir.openFile(io_mod.getIo(), auth_file_name, .{ - .mode = .read_only, - .allow_directory = false, - .follow_symlinks = false, - .resolve_beneath = true, - }) catch |err| switch (err) { - error.FileNotFound => return .absent, - else => { - debug_trace.logf("auth", "session load failed source=file step=open err={s}", .{@errorName(err)}); - return .unusable; - }, - }; - defer file.close(io_mod.getIo()); - - const stat = file.stat(io_mod.getIo()) catch |err| { - debug_trace.logf("auth", "session load failed source=file step=stat err={s}", .{@errorName(err)}); - return .unusable; - }; - if (stat.kind != .file or stat.nlink != 1 or stat.permissions.toMode() & 0o077 != 0) { - debug_trace.logf("auth", "session load failed source=file step=permissions err=InsecureAuthFile", .{}); - return .unusable; - } - - const bytes = io_mod.readFileToEnd(alloc, &file, max_auth_file_bytes) catch |err| switch (err) { - error.OutOfMemory => return err, - else => { - debug_trace.logf("auth", "session load failed source=file step=read err={s}", .{@errorName(err)}); - return .unusable; - }, - }; - defer secret.zeroAndFree(alloc, bytes); - const session = parse(alloc, bytes) catch |err| switch (err) { - error.OutOfMemory => return err, - else => { - debug_trace.logf("auth", "session load failed source=file step=parse err={s}", .{@errorName(err)}); - return .unusable; - }, - }; - return .{ .valid = session }; -} - -fn observeKeychain(alloc: Allocator, keychain: KeychainBackend) !KeychainObservation { - const maybe_bytes = keychain.load(alloc) catch |err| switch (err) { - error.OutOfMemory => return err, - error.KeychainItemNotFound => return .absent, - else => return .{ .unavailable = err }, - }; - const bytes = maybe_bytes orelse return .absent; - defer secret.zeroAndFree(alloc, bytes); - if (bytes.len > max_auth_file_bytes) return .invalid; - const session = parse(alloc, bytes) catch |err| switch (err) { - error.OutOfMemory => return err, - else => return .invalid, - }; - return .{ .valid = session }; -} - -fn publishAndVerifyKeychain(alloc: Allocator, keychain: KeychainBackend, session: Session) !void { - const bytes = try stringify(alloc, session); - defer secret.zeroAndFree(alloc, bytes); - try keychain.store(bytes); - const persisted = keychain.load(alloc) catch |err| switch (err) { - error.KeychainItemNotFound => return error.OAuthSessionKeychainWriteMismatch, - else => return err, - } orelse return error.OAuthSessionKeychainWriteMismatch; - defer secret.zeroAndFree(alloc, persisted); - if (!std.mem.eql(u8, bytes, persisted)) return error.OAuthSessionKeychainWriteMismatch; -} - -fn authFileExists(fx_dir: *std.Io.Dir) !bool { - _ = fx_dir.statFile(io_mod.getIo(), auth_file_name, .{}) catch |err| switch (err) { - error.FileNotFound => return false, - else => return err, - }; - return true; -} - -pub const Mutation = if (host_target.is_wasm) HostMutation else NativeMutation; - -const NativeMutation = struct { - fx_dir: io_mod.VerifiedDir, - lock: io_mod.TimedAdvisoryLock, - backend: StorageBackend, - keychain: KeychainBackend, - authority: Authority = .unresolved, - - pub fn deinit(self: *Mutation) void { - self.lock.release(); - self.fx_dir.close(); - self.* = undefined; - } - - pub fn load(self: *Mutation, alloc: Allocator) !?Session { - if (self.backend == .profile_file) { - self.authority = .profile_file; - return loadFromDir(alloc, &self.fx_dir.dir, .report_open_failure); - } - return self.loadKeychainResolved(alloc); - } - - pub fn save(self: *Mutation, alloc: Allocator, session: Session) !void { - if (self.backend == .macos_keychain) { - return self.saveKeychainResolved(alloc, session); - } - self.authority = .profile_file; - return self.saveFile(alloc, session); - } - - fn saveFile(self: *Mutation, alloc: Allocator, session: Session) !void { + pub fn save(self: *CommonNativeMutation, alloc: Allocator, session: Session) !void { const text = try stringify(alloc, session); defer secret.zeroAndFree(alloc, text); - try io_mod.durableReplaceVerified(alloc, &self.fx_dir, auth_file_name, text); - } - - pub fn delete(self: *Mutation, alloc: Allocator) !DeleteResult { - var result: DeleteResult = .{}; - if (self.backend == .macos_keychain) { - const deleted = self.keychain.delete(alloc) catch blk: { - result.local_cleanup_failed = true; - break :blk false; - }; - result.session_deleted = result.session_deleted or deleted; - } - - const file_outcome = deleteAuthFile(&self.fx_dir.dir, .{}) catch { - result.local_cleanup_failed = true; - return result; - }; - switch (file_outcome) { - .missing => {}, - .deleted => result.session_deleted = true, - .deleted_not_durable => { - result.session_deleted = true; - result.local_cleanup_failed = true; - }, - } - self.authority = .missing; - return result; - } - - fn loadKeychainResolved(self: *Mutation, alloc: Allocator) !?Session { - var file = try observeAuthFile(alloc, &self.fx_dir.dir); - defer file.deinit(alloc); - var keychain = try observeKeychain(alloc, self.keychain); - defer keychain.deinit(alloc); - - switch (selectResolution(file.state(), keychain.state())) { - .missing => { - self.authority = .missing; - return null; - }, - .keychain => { - self.authority = .keychain; - return keychain.takeSession().?; - }, - .file_defer_migration => { - self.authority = .profile_file; - return file.takeSession().?; - }, - .file_migrate => { - var session = file.takeSession().?; - errdefer session.deinit(alloc); - const migrated = try self.publishAndCleanup(alloc, session); - self.authority = if (migrated) .keychain else .profile_file; - return session; - }, - .storage_error => return keychain.storageError(), - } - } - - fn saveKeychainResolved(self: *Mutation, alloc: Allocator, session: Session) !void { - if (self.authority == .unresolved) { - self.authority = if (try authFileExists(&self.fx_dir.dir)) .profile_file else .keychain; - } - - switch (self.authority) { - .profile_file => { - try self.saveFile(alloc, session); - const migrated = try self.publishAndCleanup(alloc, session); - if (migrated) self.authority = .keychain; - }, - .keychain, .missing => { - try publishAndVerifyKeychain(alloc, self.keychain, session); - self.authority = .keychain; - }, - .unresolved => unreachable, - } + try self.inner.save(alloc, text); } - fn publishAndCleanup(self: *Mutation, alloc: Allocator, session: Session) !bool { - publishAndVerifyKeychain(alloc, self.keychain, session) catch |err| { - debug_trace.logf("auth", "session migration deferred step=publish err={s}", .{@errorName(err)}); - return false; - }; - - const outcome = deleteAuthFile(&self.fx_dir.dir, .{}) catch |err| { - const stat = self.fx_dir.dir.statFile(io_mod.getIo(), auth_file_name, .{}) catch |stat_err| switch (stat_err) { - error.FileNotFound => return error.OAuthSessionCleanupUncertain, - else => return stat_err, - }; - if (stat.kind != .file) return error.OAuthSessionCleanupUncertain; - debug_trace.logf("auth", "session migration deferred step=delete err={s}", .{@errorName(err)}); - return false; - }; - return switch (outcome) { - .deleted => true, - .missing => blk: { - try io_mod.syncVerifiedDir(self.fx_dir.dir); - break :blk true; + pub fn delete(self: *CommonNativeMutation, alloc: Allocator) !DeleteResult { + return switch (try self.inner.delete(alloc)) { + .deleted => .{ .session_deleted = true }, + .missing => .{}, + .deleted_not_durable => .{ + .session_deleted = true, + .local_cleanup_failed = true, }, - .deleted_not_durable => error.OAuthSessionCleanupUncertain, }; } }; @@ -574,72 +202,36 @@ fn isLoopbackHttpUrl(url: []const u8, require_origin: bool) bool { pub fn load(alloc: Allocator) !?Session { if (comptime host_target.is_wasm) return loadFromHost(alloc, js_host_auth.oauth_session_store); - const home = io_mod.getenv("HOME") orelse { - debug_trace.logf("auth", "session load skipped step=home err=HomeNotSet", .{}); - return null; - }; - if (storageBackend() == .macos_keychain) { - if (try beginExistingNativeMutation()) |existing| { - var mutation = existing; - defer mutation.deinit(); - return mutation.load(alloc); - } - return loadKeychainWithoutProfile(alloc); - } - var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| { - debug_trace.logf("auth", "session load failed step=open_home err={s}", .{@errorName(err)}); - return null; - }; - defer home_dir.close(io_mod.getIo()); - - var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }) catch |err| { - debug_trace.logf("auth", "session load failed step=open_profile err={s}", .{@errorName(err)}); - return null; - }; - defer fx_dir.close(io_mod.getIo()); + return loadNative(alloc, .active); +} - return loadFromDir(alloc, &fx_dir, .tolerate_open_failure); +pub fn loadStored(alloc: Allocator) !?Session { + if (comptime host_target.is_wasm) return loadFromHost(alloc, js_host_auth.oauth_session_store); + return loadNative(alloc, .inspect); } -fn loadFromHost(alloc: Allocator, store: js_host_auth.SessionStore) !?Session { - var stored = (try store.load(alloc)) orelse return null; - defer stored.deinit(alloc); - return parse(alloc, stored.bytes) catch |err| switch (err) { +fn loadNative(alloc: Allocator, intent: @import("auth_store.zig").LoadIntent) !?Session { + const bytes = (native_auth_store.load_entry(alloc, .fx_login, intent) catch |err| switch (err) { error.OutOfMemory => return err, else => { - debug_trace.logf("auth", "session load failed step=parse err={s}", .{@errorName(err)}); + debug_trace.logf("auth", "session load failed step=common_store err={s}", .{@errorName(err)}); return null; }, - }; -} - -fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir, mode: LoadMode) !?Session { - var file = fx_dir.openFile(io_mod.getIo(), auth_file_name, .{ - .mode = .read_only, - .allow_directory = false, - .follow_symlinks = false, - .resolve_beneath = true, - }) catch |err| switch (err) { - error.FileNotFound => return null, + }) orelse return null; + defer secret.zeroAndFree(alloc, bytes); + return parse(alloc, bytes) catch |err| switch (err) { + error.OutOfMemory => return err, else => { - debug_trace.logf("auth", "session load failed step=open_file err={s}", .{@errorName(err)}); - if (mode == .tolerate_open_failure) return null else return err; + debug_trace.logf("auth", "session load failed step=parse_common err={s}", .{@errorName(err)}); + return null; }, }; - defer file.close(io_mod.getIo()); - - const stat = try file.stat(io_mod.getIo()); - if (stat.kind != .file or stat.permissions.toMode() & 0o077 != 0) { - debug_trace.logf("auth", "session load failed step=permissions err=InsecureAuthFile", .{}); - return null; - } +} - const bytes = try io_mod.readFileToEnd(alloc, &file, max_auth_file_bytes); - defer secret.zeroAndFree(alloc, bytes); - return parse(alloc, bytes) catch |err| switch (err) { +fn loadFromHost(alloc: Allocator, store: js_host_auth.SessionStore) !?Session { + var stored = (try store.load(alloc)) orelse return null; + defer stored.deinit(alloc); + return parse(alloc, stored.bytes) catch |err| switch (err) { error.OutOfMemory => return err, else => { debug_trace.logf("auth", "session load failed step=parse err={s}", .{@errorName(err)}); @@ -655,7 +247,7 @@ pub fn saveNewSession(alloc: Allocator, session: Session) !void { try mutation.captureRevision(alloc); return mutation.save(alloc, session); } - var mutation = try beginMutation(); + var mutation = try beginExistingMutation() orelse return error.HomeNotSet; defer mutation.deinit(); try mutation.save(alloc, session); } @@ -664,133 +256,7 @@ pub fn beginExistingMutation() !?Mutation { if (comptime host_target.is_wasm) { return @as(?Mutation, HostMutation.init(js_host_auth.oauth_session_store)); } - if (storageBackend() == .macos_keychain) { - return @as(?Mutation, try beginMutation()); - } - return beginExistingNativeMutation(); -} - -fn beginExistingNativeMutation() !?Mutation { - const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; - var home_dir = io_mod.VerifiedDir{ - .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), - }; - defer home_dir.close(); - - const fx_dir = openExistingPrivateFxDir(&home_dir) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; - return @as(?Mutation, try lockMutation(fx_dir)); -} - -fn loadKeychainWithoutProfile(alloc: Allocator) !?Session { - var keychain = try observeKeychain(alloc, native_keychain_backend); - defer keychain.deinit(alloc); - - if (try beginExistingNativeMutation()) |existing| { - var mutation = existing; - defer mutation.deinit(); - return mutation.load(alloc); - } - - return switch (selectResolution(.absent, keychain.state())) { - .missing => null, - .keychain => keychain.takeSession().?, - .storage_error => keychain.storageError(), - .file_migrate, .file_defer_migration => unreachable, - }; -} - -fn beginMutation() !Mutation { - const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; - var home_dir = io_mod.VerifiedDir{ - .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), - }; - defer home_dir.close(); - - const fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&home_dir, profile_paths.root_dir_name); - return lockMutation(fx_dir); -} - -fn lockMutation(open_fx_dir: io_mod.VerifiedDir) !Mutation { - var probe = MutationLockProbe{ .fx_dir = open_fx_dir.dir }; - return lockMutationWithOps(open_fx_dir, mutation_lock_deadline_ms, .{ - .ctx = &probe, - .try_lock = MutationLockProbe.tryLock, - }); -} - -fn lockMutationWithOps( - open_fx_dir: io_mod.VerifiedDir, - deadline_ms: u64, - ops: io_mod.LockOps, -) !Mutation { - return lockMutationWithBackend( - open_fx_dir, - deadline_ms, - ops, - storageBackend(), - native_keychain_backend, - ); -} - -fn lockMutationWithBackend( - open_fx_dir: io_mod.VerifiedDir, - deadline_ms: u64, - ops: io_mod.LockOps, - backend: StorageBackend, - keychain: KeychainBackend, -) !Mutation { - var fx_dir = open_fx_dir; - errdefer fx_dir.close(); - - var lock = try io_mod.acquireTimedAdvisoryLockWithOps( - &fx_dir, - mutation_lock_file_name, - deadline_ms, - ops, - ); - errdefer lock.release(); - - return .{ - .fx_dir = fx_dir, - .lock = lock, - .backend = backend, - .keychain = keychain, - }; -} - -fn openExistingPrivateFxDir(home_dir: *io_mod.VerifiedDir) !io_mod.VerifiedDir { - var dir = try home_dir.dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }); - errdefer dir.close(io_mod.getIo()); - - const initial_stat = try dir.stat(io_mod.getIo()); - if (initial_stat.kind != .directory) return error.DurablePathUnsafe; - if (initial_stat.permissions.toMode() & 0o200 == 0) { - return error.PrivateStatePermissionsUnsupported; - } - dir.setPermissions(io_mod.getIo(), std.Io.File.Permissions.fromMode(0o700)) catch { - return error.PrivateStatePermissionsUnsupported; - }; - const stat = try dir.stat(io_mod.getIo()); - if (stat.kind != .directory) return error.DurablePathUnsafe; - if (stat.permissions.toMode() & 0o777 != 0o700) { - return error.PrivateStatePermissionsUnsupported; - } - return .{ .dir = dir }; -} - -fn deleteAuthFile(fx_dir: *std.Io.Dir, ops: io_mod.DurableOps) !DeleteOutcome { - fx_dir.deleteFile(io_mod.getIo(), auth_file_name) catch |err| switch (err) { - error.FileNotFound => return .missing, - else => return err, - }; - ops.sync_dir(ops.ctx, fx_dir.*) catch return .deleted_not_durable; - return .deleted; + return .{ .inner = try native_auth_store.begin_entry_mutation(.fx_login) }; } pub fn parse(alloc: Allocator, bytes: []const u8) !Session { @@ -953,11 +419,6 @@ fn check_parse_allocation_failures(alloc: Allocator) !void { defer session.deinit(alloc); } -fn check_load_allocation_failures(alloc: Allocator, dir: *std.Io.Dir) !void { - var session = (try loadFromDir(alloc, dir, .report_open_failure)) orelse return error.TestUnexpectedMissingSession; - defer session.deinit(alloc); -} - test "oauth session stringifies and parses" { var session = Session{ .issuer = try std.testing.allocator.dupe(u8, issuer), @@ -983,254 +444,6 @@ test "oauth session stringifies and parses" { try std.testing.expectEqualStrings("team_123", parsed.team_id.?); } -test "OAuth storage backend selection is platform scoped and explicitly disableable" { - try std.testing.expectEqual(StorageBackend.macos_keychain, selectStorageBackend(.macos, false)); - try std.testing.expectEqual(StorageBackend.profile_file, selectStorageBackend(.macos, true)); - try std.testing.expectEqual(StorageBackend.profile_file, selectStorageBackend(.linux, false)); - try std.testing.expectEqual(StorageBackend.profile_file, selectStorageBackend(.windows, false)); -} - -test "OAuth test builds use profile file storage" { - try std.testing.expectEqual(StorageBackend.profile_file, storageBackend()); -} - -test "OAuth source resolution covers every file and Keychain state" { - const cases = [_]struct { - file: FileState, - keychain: KeychainState, - expected: Resolution, - }{ - .{ .file = .absent, .keychain = .absent, .expected = .missing }, - .{ .file = .absent, .keychain = .valid, .expected = .keychain }, - .{ .file = .absent, .keychain = .invalid, .expected = .storage_error }, - .{ .file = .absent, .keychain = .unavailable, .expected = .storage_error }, - .{ .file = .valid, .keychain = .absent, .expected = .file_migrate }, - .{ .file = .valid, .keychain = .valid, .expected = .file_migrate }, - .{ .file = .valid, .keychain = .invalid, .expected = .file_migrate }, - .{ .file = .valid, .keychain = .unavailable, .expected = .file_defer_migration }, - .{ .file = .unusable, .keychain = .valid, .expected = .keychain }, - .{ .file = .unusable, .keychain = .absent, .expected = .missing }, - .{ .file = .unusable, .keychain = .invalid, .expected = .storage_error }, - .{ .file = .unusable, .keychain = .unavailable, .expected = .storage_error }, - }; - - for (cases) |case| { - try std.testing.expectEqual(case.expected, selectResolution(case.file, case.keychain)); - } -} - -const alternate_test_session_json = "{\"version\":1,\"issuer\":\"https://vercel.com\",\"client_id\":\"client\",\"access_token\":\"keychain-access\",\"refresh_token\":\"keychain-refresh\",\"expires_at_ms\":2,\"scope\":\"openid offline_access\",\"token_type\":\"Bearer\"}"; - -const FakeOAuthKeychain = struct { - alloc: Allocator, - value: ?[]u8 = null, - store_calls: usize = 0, - delete_calls: usize = 0, - fail_store: bool = false, - fail_delete: bool = false, - - fn deinit(self: *FakeOAuthKeychain) void { - if (self.value) |value| secret.zeroAndFree(self.alloc, value); - self.* = undefined; - } - - fn backend(self: *FakeOAuthKeychain) KeychainBackend { - return .{ - .context = self, - .load_fn = loadCallback, - .store_fn = storeCallback, - .delete_fn = deleteCallback, - }; - } - - fn loadCallback(raw: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { - const self: *FakeOAuthKeychain = @ptrCast(@alignCast(raw.?)); - const value = self.value orelse return null; - return try alloc.dupe(u8, value); - } - - fn storeCallback(raw: ?*anyopaque, value: []const u8) KeychainError!void { - const self: *FakeOAuthKeychain = @ptrCast(@alignCast(raw.?)); - self.store_calls += 1; - if (self.fail_store) return error.KeychainWriteFailed; - const replacement = try self.alloc.dupe(u8, value); - if (self.value) |previous| secret.zeroAndFree(self.alloc, previous); - self.value = replacement; - } - - fn deleteCallback(raw: ?*anyopaque, _: Allocator) KeychainError!bool { - const self: *FakeOAuthKeychain = @ptrCast(@alignCast(raw.?)); - self.delete_calls += 1; - if (self.fail_delete) return error.KeychainDeleteFailed; - const previous = self.value orelse return false; - secret.zeroAndFree(self.alloc, previous); - self.value = null; - return true; - } -}; - -fn writeTestAuthFile(dir: std.Io.Dir, contents: []const u8) !void { - var file = try dir.createFile(std.testing.io, auth_file_name, .{ - .truncate = true, - .permissions = std.Io.File.Permissions.fromMode(0o600), - }); - defer file.close(std.testing.io); - try file.writeStreamingAll(std.testing.io, contents); -} - -fn testKeychainMutation(dir: std.Io.Dir, keychain: KeychainBackend) !Mutation { - return lockMutationWithBackend( - .{ .dir = try dir.openDir(std.testing.io, ".", .{ .iterate = true }) }, - 0, - .{}, - .macos_keychain, - keychain, - ); -} - -test "OAuth migration keeps a valid file authoritative until verified cleanup" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try writeTestAuthFile(tmp.dir, test_session_json); - - var fake = FakeOAuthKeychain{ - .alloc = alloc, - .value = try alloc.dupe(u8, alternate_test_session_json), - }; - defer fake.deinit(); - var mutation = try testKeychainMutation(tmp.dir, fake.backend()); - defer mutation.deinit(); - - var loaded = (try mutation.load(alloc)).?; - defer loaded.deinit(alloc); - try std.testing.expectEqualStrings("access", loaded.access_token); - try std.testing.expectEqual(@as(usize, 1), fake.store_calls); - try std.testing.expectEqual(Authority.keychain, mutation.authority); - try std.testing.expectError( - error.FileNotFound, - tmp.dir.statFile(std.testing.io, auth_file_name, .{}), - ); - - var persisted = try parse(alloc, fake.value.?); - defer persisted.deinit(alloc); - try std.testing.expectEqualStrings("access", persisted.access_token); - - secret.zeroAndFree(alloc, loaded.access_token); - loaded.access_token = try alloc.dupe(u8, "keychain-only-update"); - try mutation.save(alloc, loaded); - try std.testing.expectError( - error.FileNotFound, - tmp.dir.statFile(std.testing.io, auth_file_name, .{}), - ); - var updated = try parse(alloc, fake.value.?); - defer updated.deinit(alloc); - try std.testing.expectEqualStrings("keychain-only-update", updated.access_token); -} - -test "new OAuth login replaces an existing file before deferred migration" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try writeTestAuthFile(tmp.dir, test_session_json); - - var fake = FakeOAuthKeychain{ .alloc = alloc, .fail_store = true }; - defer fake.deinit(); - var mutation = try testKeychainMutation(tmp.dir, fake.backend()); - defer mutation.deinit(); - var replacement = try parse(alloc, alternate_test_session_json); - defer replacement.deinit(alloc); - - try mutation.save(alloc, replacement); - var persisted = (try loadFromDir(alloc, &tmp.dir, .report_open_failure)).?; - defer persisted.deinit(alloc); - try std.testing.expectEqualStrings("keychain-access", persisted.access_token); - try std.testing.expectEqual(Authority.profile_file, mutation.authority); -} - -test "OAuth migration preserves and updates the file when Keychain publication fails" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try writeTestAuthFile(tmp.dir, test_session_json); - - var fake = FakeOAuthKeychain{ .alloc = alloc, .fail_store = true }; - defer fake.deinit(); - var mutation = try testKeychainMutation(tmp.dir, fake.backend()); - defer mutation.deinit(); - - var loaded = (try mutation.load(alloc)).?; - defer loaded.deinit(alloc); - try std.testing.expectEqual(Authority.profile_file, mutation.authority); - secret.zeroAndFree(alloc, loaded.access_token); - loaded.access_token = try alloc.dupe(u8, "updated-access"); - try mutation.save(alloc, loaded); - - var persisted = (try loadFromDir(alloc, &tmp.dir, .report_open_failure)).?; - defer persisted.deinit(alloc); - try std.testing.expectEqualStrings("updated-access", persisted.access_token); - try std.testing.expectEqual(@as(usize, 2), fake.store_calls); -} - -test "OAuth resolver uses valid Keychain state without deleting an unusable file" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try writeTestAuthFile(tmp.dir, "not-json"); - - var fake = FakeOAuthKeychain{ - .alloc = alloc, - .value = try alloc.dupe(u8, alternate_test_session_json), - }; - defer fake.deinit(); - var mutation = try testKeychainMutation(tmp.dir, fake.backend()); - defer mutation.deinit(); - - var loaded = (try mutation.load(alloc)).?; - defer loaded.deinit(alloc); - try std.testing.expectEqualStrings("keychain-access", loaded.access_token); - _ = try tmp.dir.statFile(std.testing.io, auth_file_name, .{}); -} - -test "OAuth resolver reports invalid Keychain state when no valid file exists" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var fake = FakeOAuthKeychain{ - .alloc = alloc, - .value = try alloc.dupe(u8, "not-json"), - }; - defer fake.deinit(); - var mutation = try testKeychainMutation(tmp.dir, fake.backend()); - defer mutation.deinit(); - - try std.testing.expectError(error.InvalidOAuthKeychainSession, mutation.load(alloc)); -} - -test "OAuth logout deletion attempts Keychain and file cleanup independently" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try writeTestAuthFile(tmp.dir, test_session_json); - var fake = FakeOAuthKeychain{ - .alloc = alloc, - .value = try alloc.dupe(u8, alternate_test_session_json), - .fail_delete = true, - }; - defer fake.deinit(); - var mutation = try testKeychainMutation(tmp.dir, fake.backend()); - defer mutation.deinit(); - - const result = try mutation.delete(alloc); - try std.testing.expect(result.session_deleted); - try std.testing.expect(result.local_cleanup_failed); - try std.testing.expectEqual(@as(usize, 1), fake.delete_calls); - try std.testing.expectError( - error.FileNotFound, - tmp.dir.statFile(std.testing.io, auth_file_name, .{}), - ); -} - test "JS host OAuth session load commit and remove preserve the native format and revision" { var state: HostStoreTestState = .{}; var loaded = (try loadFromHost(std.testing.allocator, state.provider())).?; @@ -1278,39 +491,6 @@ test "oauth session parse rejects non-object JSON" { try std.testing.expectError(error.InvalidAuthSession, parse(std.testing.allocator, "[]")); } -test "oauth session loading propagates allocation failures" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - var file = try tmp.dir.createFile(std.testing.io, auth_file_name, .{ - .permissions = std.Io.File.Permissions.fromMode(0o600), - }); - try file.writeStreamingAll( - std.testing.io, - test_session_json, - ); - file.close(std.testing.io); - - try std.testing.checkAllAllocationFailures( - std.testing.allocator, - check_load_allocation_failures, - .{&tmp.dir}, - ); -} - -test "OAuth mutation loads report auth file open failures" { - if (comptime @import("builtin").os.tag == .windows) return error.SkipZigTest; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.symLink(std.testing.io, "missing-auth-target", auth_file_name, .{ .is_directory = false }); - - try std.testing.expect((try loadFromDir(std.testing.allocator, &tmp.dir, .tolerate_open_failure)) == null); - try std.testing.expectError( - error.SymLinkLoop, - loadFromDir(std.testing.allocator, &tmp.dir, .report_open_failure), - ); -} - test "oauth session rejects invalid saved issuers" { try std.testing.expectError( error.InvalidAuthSession, @@ -1337,84 +517,6 @@ test "oauth session treats near-expiry as expired" { try std.testing.expect(session.expired(std.math.maxInt(i64))); } -const DeleteSyncProbe = struct { - sync_count: usize = 0, - fail: bool = false, - - fn syncDir(raw_ctx: ?*anyopaque, _: std.Io.Dir) anyerror!void { - const self: *DeleteSyncProbe = @ptrCast(@alignCast(raw_ctx.?)); - self.sync_count += 1; - if (self.fail) return error.InjectedSyncFailure; - } -}; - -test "OAuth session deletion reports deleted and missing files" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - var file = try tmp.dir.createFile(std.testing.io, auth_file_name, .{}); - file.close(std.testing.io); - - var probe: DeleteSyncProbe = .{}; - const ops = io_mod.DurableOps{ - .ctx = &probe, - .sync_dir = DeleteSyncProbe.syncDir, - }; - const deleted = try deleteAuthFile(&tmp.dir, ops); - try std.testing.expectEqual(DeleteOutcome.deleted, deleted); - try std.testing.expectEqual(@as(usize, 1), probe.sync_count); - const missing = try deleteAuthFile(&tmp.dir, ops); - try std.testing.expectEqual(DeleteOutcome.missing, missing); - try std.testing.expectEqual(@as(usize, 1), probe.sync_count); -} - -test "OAuth session deletion reports directory sync failure after unlink" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - var file = try tmp.dir.createFile(std.testing.io, auth_file_name, .{}); - file.close(std.testing.io); - - var probe = DeleteSyncProbe{ .fail = true }; - const ops = io_mod.DurableOps{ - .ctx = &probe, - .sync_dir = DeleteSyncProbe.syncDir, - }; - const outcome = try deleteAuthFile(&tmp.dir, ops); - try std.testing.expectEqual(DeleteOutcome.deleted_not_durable, outcome); - try std.testing.expectEqual(@as(usize, 1), probe.sync_count); - try std.testing.expectError( - error.FileNotFound, - tmp.dir.statFile(std.testing.io, auth_file_name, .{}), - ); - - probe.fail = false; - const missing = try deleteAuthFile(&tmp.dir, ops); - try std.testing.expectEqual(DeleteOutcome.missing, missing); - try std.testing.expectEqual(@as(usize, 1), probe.sync_count); -} - -test "OAuth session mutation lock serializes independent handles" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - var first = try lockMutationWithOps( - .{ .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }) }, - 0, - .{}, - ); - defer first.deinit(); - - try std.testing.expectError( - error.LockBusy, - lockMutationWithOps( - .{ .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }) }, - 0, - .{}, - ), - ); -} - test "oauth E2E issuer override accepts loopback HTTP only" { try std.testing.expectEqualStrings( "http://127.0.0.1:43123", diff --git a/src/core/hosts/host.zig b/src/core/hosts/host.zig index 10b3eeb4d..0bee45caf 100644 --- a/src/core/hosts/host.zig +++ b/src/core/hosts/host.zig @@ -95,6 +95,10 @@ pub const SecretStore = struct { ?*anyopaque, std.mem.Allocator, ) SecretStoreLoadError!?[]u8, + load_stored_fn: ?*const fn ( + ?*anyopaque, + std.mem.Allocator, + ) SecretStoreLoadError!?[]u8 = null, store_fn: *const fn ( ?*anyopaque, std.mem.Allocator, @@ -117,6 +121,15 @@ pub const SecretStore = struct { return self.load_fn(self.context, alloc); } + pub fn loadStored( + self: SecretStore, + alloc: std.mem.Allocator, + ) SecretStoreLoadError!?[]u8 { + // Hosts with migration-sensitive storage provide a read-only path. + const load_stored = self.load_stored_fn orelse self.load_fn; + return load_stored(self.context, alloc); + } + /// Borrows `value` for this call. The caller retains ownership. pub fn store( self: SecretStore, @@ -362,6 +375,7 @@ test "unavailable URL opener keeps the manual fallback available" { test "unavailable secret store reports absence and refuses writes" { try std.testing.expect(!unavailable_secret_store.isDisabled()); try std.testing.expect((try unavailable_secret_store.load(std.testing.allocator)) == null); + try std.testing.expect((try unavailable_secret_store.loadStored(std.testing.allocator)) == null); try std.testing.expectError( error.StoredKeyWriteFailed, unavailable_secret_store.store(std.testing.allocator, "secret"), diff --git a/src/core/hosts/native_auth_store.zig b/src/core/hosts/native_auth_store.zig new file mode 100644 index 000000000..eb3bbdc7d --- /dev/null +++ b/src/core/hosts/native_auth_store.zig @@ -0,0 +1,1244 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const auth_store = @import("../auth/auth_store.zig"); +const debug_trace = @import("../shared/debug_trace.zig"); +const io_mod = @import("../shared/io.zig"); +const native_keychain = @import("native_keychain.zig"); +const profile_paths = @import("../shared/profile_paths.zig"); +const secret = @import("../auth/secret.zig"); + +const Allocator = std.mem.Allocator; +const max_auth_document_bytes: usize = 256 * 1024; +const max_legacy_api_key_bytes: usize = 8 * 1024; +const max_legacy_session_bytes: usize = 64 * 1024; +const mutation_lock_file_name = "auth.lock"; +const mutation_lock_deadline_ms: u64 = 2000; +const e2e_lock_contention_file_name = "auth-lock-contention"; + +const MutationLockProbe = struct { + fx_dir: std.Io.Dir, + signaled: bool = false, + + fn try_lock(raw: ?*anyopaque, file: std.Io.File) anyerror!bool { + const locked = try file.tryLock(io_mod.getIo(), .exclusive); + const self: *MutationLockProbe = @ptrCast(@alignCast(raw.?)); + if (!locked and !self.signaled) { + signal_e2e_lock_contention(self.fx_dir); + self.signaled = true; + } + return locked; + } +}; + +fn signal_e2e_lock_contention(fx_dir: std.Io.Dir) void { + const enabled = io_mod.getenv("FX_E2E_AUTH_LOCK_CONTENTION") orelse return; + if (!std.mem.eql(u8, enabled, "1")) return; + var file = fx_dir.createFile(io_mod.getIo(), e2e_lock_contention_file_name, .{ + .truncate = true, + .permissions = std.Io.File.Permissions.fromMode(0o600), + }) catch return; + defer file.close(io_mod.getIo()); + file.writeStreamingAll(io_mod.getIo(), "contended\n") catch {}; +} + +const StorageBackend = enum { + profile_file, + macos_keychain, +}; + +const KeychainError = Allocator.Error || native_keychain.Error; + +fn storage_backend() StorageBackend { + if (comptime builtin.is_test) return .profile_file; + if (comptime builtin.os.tag == .macos) { + if (!native_keychain.isDisabled()) return .macos_keychain; + } + return .profile_file; +} + +const KeychainBackend = struct { + context: ?*anyopaque = null, + load_document_fn: *const fn (?*anyopaque, Allocator) KeychainError!?[]u8, + store_document_fn: *const fn (?*anyopaque, []const u8) KeychainError!void, + load_api_key_fn: *const fn (?*anyopaque, Allocator) KeychainError!?[]u8, + delete_api_key_fn: *const fn (?*anyopaque, Allocator) KeychainError!bool, + + fn load_document(self: KeychainBackend, alloc: Allocator) KeychainError!?[]u8 { + return self.load_document_fn(self.context, alloc); + } + + fn store_document(self: KeychainBackend, value: []const u8) KeychainError!void { + return self.store_document_fn(self.context, value); + } + + fn load_api_key(self: KeychainBackend, alloc: Allocator) KeychainError!?[]u8 { + return self.load_api_key_fn(self.context, alloc); + } + + fn delete_api_key(self: KeychainBackend, alloc: Allocator) KeychainError!bool { + return self.delete_api_key_fn(self.context, alloc); + } +}; + +const native_keychain_backend = KeychainBackend{ + .load_document_fn = native_load_document, + .store_document_fn = native_store_document, + .load_api_key_fn = native_load_api_key, + .delete_api_key_fn = native_delete_api_key, +}; + +fn native_load_document(_: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { + return native_keychain.loadOAuthSession(alloc) catch |err| switch (err) { + error.KeychainItemNotFound => null, + else => return err, + }; +} + +fn native_store_document(_: ?*anyopaque, value: []const u8) KeychainError!void { + return native_keychain.storeOAuthSession(value); +} + +fn native_load_api_key(_: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { + return native_keychain.load(alloc) catch |err| switch (err) { + error.KeychainItemNotFound => null, + else => return err, + }; +} + +fn native_delete_api_key(_: ?*anyopaque, alloc: Allocator) KeychainError!bool { + return native_keychain.delete(alloc); +} + +const ProfileMutation = struct { + fx_dir: io_mod.VerifiedDir, + lock: io_mod.TimedAdvisoryLock, + + fn deinit(self: *ProfileMutation) void { + self.lock.release(); + self.fx_dir.close(); + self.* = undefined; + } + + fn load(self: *ProfileMutation, alloc: Allocator) !?auth_store.Document { + var file = self.fx_dir.dir.openFile(io_mod.getIo(), profile_paths.auth_file_name, .{ + .mode = .read_only, + .allow_directory = false, + .follow_symlinks = false, + .resolve_beneath = true, + }) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + defer file.close(io_mod.getIo()); + + const stat = try file.stat(io_mod.getIo()); + if (stat.kind != .file or stat.nlink != 1 or stat.permissions.toMode() & 0o077 != 0) { + return error.AuthDocumentInsecure; + } + const bytes = try io_mod.readFileToEnd(alloc, &file, max_auth_document_bytes); + defer secret.zeroAndFree(alloc, bytes); + return try auth_store.Document.parse(alloc, bytes); + } + + fn commit( + self: *ProfileMutation, + alloc: Allocator, + document: auth_store.Document, + ) !void { + const bytes = try document.stringify(alloc); + defer secret.zeroAndFree(alloc, bytes); + try io_mod.durableReplaceVerified( + alloc, + &self.fx_dir, + profile_paths.auth_file_name, + bytes, + ); + } +}; + +fn begin_profile_mutation(home: []const u8) !ProfileMutation { + var home_dir = io_mod.VerifiedDir{ + .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), + }; + defer home_dir.close(); + + var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&home_dir, profile_paths.root_dir_name); + errdefer fx_dir.close(); + var probe = MutationLockProbe{ .fx_dir = fx_dir.dir }; + var lock = try io_mod.acquireTimedAdvisoryLockWithOps( + &fx_dir, + mutation_lock_file_name, + mutation_lock_deadline_ms, + .{ .ctx = &probe, .try_lock = MutationLockProbe.try_lock }, + ); + errdefer lock.release(); + return .{ .fx_dir = fx_dir, .lock = lock }; +} + +const ProfileObservation = struct { + state: auth_store.StoreState, + document: ?auth_store.Document = null, + + fn deinit(self: *ProfileObservation, alloc: Allocator) void { + if (self.document) |*document| document.deinit(alloc); + self.* = .{ .state = .empty }; + } + + fn take_document(self: *ProfileObservation) ?auth_store.Document { + const document = self.document; + self.document = null; + return document; + } +}; + +pub const DeleteOutcome = enum { + deleted, + missing, + deleted_not_durable, +}; + +pub const EntryMutation = struct { + profile: ProfileMutation, + source: auth_store.StoredSource, + backend: StorageBackend = .profile_file, + keychain: KeychainBackend = native_keychain_backend, + + pub fn deinit(self: *EntryMutation) void { + self.profile.deinit(); + self.* = undefined; + } + + pub fn load(self: *EntryMutation, alloc: Allocator) !?[]u8 { + var observation = switch (self.backend) { + .profile_file => try observe_profile(alloc, &self.profile.fx_dir.dir), + .macos_keychain => try observe_keychain_profile(alloc, &self.profile.fx_dir.dir, self.keychain), + }; + defer observation.deinit(alloc); + try self.migrate_observation(alloc, &observation); + const document = observation.document orelse return null; + const value = document.get(self.source) orelse return null; + return try alloc.dupe(u8, value); + } + + fn migrate_observation( + self: *EntryMutation, + alloc: Allocator, + observation: *ProfileObservation, + ) !void { + switch (auth_store.decide_load(observation.state, .active)) { + .missing, .use_current => return, + .migrate_legacy => {}, + .use_legacy, .reject_current => unreachable, + } + const document = observation.document orelse return error.InvalidAuthDocument; + const migrated = switch (self.backend) { + .profile_file => publish_profile_migration(&self.profile, alloc, document), + .macos_keychain => publish_keychain_migration( + alloc, + &self.profile.fx_dir.dir, + document, + self.keychain, + ), + }; + if (migrated) { + observation.state = .current; + } + } + + pub fn save(self: *EntryMutation, alloc: Allocator, value: []const u8) !void { + var observation = switch (self.backend) { + .profile_file => try observe_profile(alloc, &self.profile.fx_dir.dir), + .macos_keychain => try observe_keychain_profile(alloc, &self.profile.fx_dir.dir, self.keychain), + }; + defer observation.deinit(alloc); + const empty: auth_store.Document = .{}; + const document = if (observation.document) |*current| current else ∅ + var next = try document.replaced(alloc, self.source, value); + defer next.deinit(alloc); + switch (self.backend) { + .profile_file => { + try commit_and_verify(&self.profile, alloc, next); + delete_legacy_profile_files(&self.profile.fx_dir.dir) catch |err| { + debug_trace.logf("auth", "common auth cleanup incomplete backend=profile err={s}", .{@errorName(err)}); + }; + }, + .macos_keychain => { + try commit_and_verify_keychain(alloc, next, self.keychain); + delete_keychain_legacy_profile_files(&self.profile.fx_dir.dir) catch |err| { + debug_trace.logf("auth", "common auth cleanup incomplete backend=keychain source=profile err={s}", .{@errorName(err)}); + }; + _ = self.keychain.delete_api_key(alloc) catch |err| failed: { + debug_trace.logf("auth", "common auth cleanup incomplete backend=keychain source=stored_key err={s}", .{@errorName(err)}); + break :failed false; + }; + }, + } + } + + pub fn delete(self: *EntryMutation, alloc: Allocator) !DeleteOutcome { + var observation = switch (self.backend) { + .profile_file => try observe_profile(alloc, &self.profile.fx_dir.dir), + .macos_keychain => try observe_keychain_profile(alloc, &self.profile.fx_dir.dir, self.keychain), + }; + defer observation.deinit(alloc); + const document = observation.document orelse return .missing; + if (document.get(self.source) == null) return .missing; + var next = try document.removed(alloc, self.source); + defer next.deinit(alloc); + var cleanup_failed = false; + switch (self.backend) { + .profile_file => { + commit_and_verify(&self.profile, alloc, next) catch |err| switch (err) { + error.DurableReplacePostRenameFailed => return .deleted_not_durable, + else => return err, + }; + delete_legacy_profile_files(&self.profile.fx_dir.dir) catch |err| { + debug_trace.logf("auth", "common auth cleanup incomplete backend=profile err={s}", .{@errorName(err)}); + cleanup_failed = true; + }; + }, + .macos_keychain => { + try commit_and_verify_keychain(alloc, next, self.keychain); + delete_keychain_legacy_profile_files(&self.profile.fx_dir.dir) catch |err| { + debug_trace.logf("auth", "common auth cleanup incomplete backend=keychain source=profile err={s}", .{@errorName(err)}); + cleanup_failed = true; + }; + _ = self.keychain.delete_api_key(alloc) catch |err| failed: { + debug_trace.logf("auth", "common auth cleanup incomplete backend=keychain source=stored_key err={s}", .{@errorName(err)}); + cleanup_failed = true; + break :failed false; + }; + }, + } + return if (cleanup_failed) .deleted_not_durable else .deleted; + } +}; + +pub fn load_entry( + alloc: Allocator, + source: auth_store.StoredSource, + intent: auth_store.LoadIntent, +) !?[]u8 { + const home = io_mod.getenv("HOME") orelse return null; + return switch (storage_backend()) { + .profile_file => load_profile_entry(alloc, home, source, intent), + .macos_keychain => blk: { + var document = (try load_keychain_document( + alloc, + home, + intent, + native_keychain_backend, + )) orelse break :blk null; + defer document.deinit(alloc); + const value = document.get(source) orelse break :blk null; + break :blk try alloc.dupe(u8, value); + }, + }; +} + +pub fn begin_entry_mutation(source: auth_store.StoredSource) !EntryMutation { + const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; + var mutation = try begin_profile_entry_mutation(home, source); + mutation.backend = storage_backend(); + return mutation; +} + +fn begin_profile_entry_mutation( + home: []const u8, + source: auth_store.StoredSource, +) !EntryMutation { + return .{ + .profile = try begin_profile_mutation(home), + .source = source, + }; +} + +fn load_profile_entry( + alloc: Allocator, + home: []const u8, + source: auth_store.StoredSource, + intent: auth_store.LoadIntent, +) !?[]u8 { + var document = (try load_profile_document(alloc, home, intent)) orelse return null; + defer document.deinit(alloc); + const value = document.get(source) orelse return null; + return try alloc.dupe(u8, value); +} + +fn commit_and_verify( + mutation: *ProfileMutation, + alloc: Allocator, + document: auth_store.Document, +) !void { + try mutation.commit(alloc, document); + var verified = (try mutation.load(alloc)) orelse return error.AuthDocumentWriteMismatch; + defer verified.deinit(alloc); + if (!document.eql(verified)) return error.AuthDocumentWriteMismatch; +} + +fn publish_profile_migration( + mutation: *ProfileMutation, + alloc: Allocator, + document: auth_store.Document, +) bool { + commit_and_verify(mutation, alloc, document) catch |err| { + debug_trace.logf("auth", "common auth migration deferred backend=profile step=publish err={s}", .{@errorName(err)}); + return false; + }; + delete_legacy_profile_files(&mutation.fx_dir.dir) catch |err| { + debug_trace.logf("auth", "common auth migration cleanup incomplete backend=profile err={s}", .{@errorName(err)}); + }; + return true; +} + +fn load_profile_document( + alloc: Allocator, + home: []const u8, + intent: auth_store.LoadIntent, +) !?auth_store.Document { + var initial = try observe_profile_home(alloc, home); + defer initial.deinit(alloc); + switch (auth_store.decide_load(initial.state, intent)) { + .missing => return null, + .use_legacy, .use_current => return initial.take_document(), + .reject_current => unreachable, + .migrate_legacy => {}, + } + + var mutation = try begin_profile_mutation(home); + defer mutation.deinit(); + var observation = try observe_profile(alloc, &mutation.fx_dir.dir); + defer observation.deinit(alloc); + return switch (auth_store.decide_load(observation.state, intent)) { + .missing => null, + .use_current => observation.take_document(), + .migrate_legacy => migrated: { + const document = observation.document orelse return error.InvalidAuthDocument; + _ = publish_profile_migration(&mutation, alloc, document); + break :migrated observation.take_document(); + }, + .use_legacy, .reject_current => unreachable, + }; +} + +fn observe_profile_home(alloc: Allocator, home: []const u8) !ProfileObservation { + var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => return .{ .state = .empty }, + else => return err, + }; + defer home_dir.close(io_mod.getIo()); + var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ + .iterate = true, + .follow_symlinks = false, + }) catch |err| switch (err) { + error.FileNotFound => return .{ .state = .empty }, + else => return err, + }; + defer fx_dir.close(io_mod.getIo()); + return observe_profile(alloc, &fx_dir); +} + +fn load_keychain_document( + alloc: Allocator, + home: []const u8, + intent: auth_store.LoadIntent, + keychain: KeychainBackend, +) !?auth_store.Document { + var initial = try observe_keychain_home(alloc, home, keychain); + defer initial.deinit(alloc); + switch (auth_store.decide_load(initial.state, intent)) { + .missing => return null, + .use_legacy, .use_current => return initial.take_document(), + .reject_current => unreachable, + .migrate_legacy => {}, + } + + var mutation = try begin_profile_mutation(home); + defer mutation.deinit(); + var observation = try observe_keychain_profile(alloc, &mutation.fx_dir.dir, keychain); + defer observation.deinit(alloc); + return switch (auth_store.decide_load(observation.state, intent)) { + .missing => null, + .use_current => observation.take_document(), + .migrate_legacy => migrated: { + const document = observation.document orelse return error.InvalidAuthDocument; + _ = publish_keychain_migration(alloc, &mutation.fx_dir.dir, document, keychain); + break :migrated observation.take_document(); + }, + .use_legacy, .reject_current => unreachable, + }; +} + +fn observe_keychain_home( + alloc: Allocator, + home: []const u8, + keychain: KeychainBackend, +) !ProfileObservation { + var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => return observe_keychain_without_profile(alloc, keychain), + else => return err, + }; + defer home_dir.close(io_mod.getIo()); + var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ + .iterate = true, + .follow_symlinks = false, + }) catch |err| switch (err) { + error.FileNotFound => return observe_keychain_without_profile(alloc, keychain), + else => return err, + }; + defer fx_dir.close(io_mod.getIo()); + return observe_keychain_profile(alloc, &fx_dir, keychain); +} + +fn observe_keychain_without_profile( + alloc: Allocator, + keychain: KeychainBackend, +) !ProfileObservation { + var document: auth_store.Document = .{}; + errdefer document.deinit(alloc); + var found = false; + const stored = try keychain.load_document(alloc); + defer if (stored) |bytes| secret.zeroAndFree(alloc, bytes); + if (stored) |bytes| { + const version = (try declared_version(alloc, bytes)) orelse return error.InvalidAuthDocument; + switch (version) { + 2 => return .{ .state = .current, .document = try auth_store.Document.parse(alloc, bytes) }, + 1 => { + const next = try document.replaced(alloc, .fx_login, bytes); + document.deinit(alloc); + document = next; + found = true; + }, + else => return error.InvalidAuthDocument, + } + } + if (try keychain.load_api_key(alloc)) |key| { + defer secret.zeroAndFree(alloc, key); + const next = try document.replaced(alloc, .stored_key, key); + document.deinit(alloc); + document = next; + found = true; + } + return if (found) + .{ .state = .legacy, .document = document } + else blk: { + document.deinit(alloc); + break :blk .{ .state = .empty }; + }; +} + +fn observe_keychain_profile( + alloc: Allocator, + fx_dir: *std.Io.Dir, + keychain: KeychainBackend, +) !ProfileObservation { + const stored = try keychain.load_document(alloc); + defer if (stored) |bytes| secret.zeroAndFree(alloc, bytes); + const stored_version = if (stored) |bytes| try declared_version(alloc, bytes) else null; + var current = if (stored) |bytes| + if (stored_version == 2) + try auth_store.Document.parse(alloc, bytes) + else + null + else + null; + defer if (current) |*document| document.deinit(alloc); + if (stored_version != null and stored_version != 1 and stored_version != 2) { + return error.InvalidAuthDocument; + } + + var profile = try observe_profile(alloc, fx_dir); + defer profile.deinit(alloc); + if (profile.state == .malformed_current) return error.InvalidAuthDocument; + var document = profile.take_document() orelse auth_store.Document{}; + errdefer document.deinit(alloc); + var found = profile.state != .empty; + var needs_migration = found; + if (current) |*keychain_document| { + const merged = try merge_auth_documents( + alloc, + keychain_document, + &document, + ); + document.deinit(alloc); + document = merged; + found = true; + } + if (stored) |bytes| { + if (document.get(.fx_login) == null) { + if (stored_version == 1) { + const next = try document.replaced(alloc, .fx_login, bytes); + document.deinit(alloc); + document = next; + found = true; + needs_migration = true; + } + } + if (stored_version == null and !found) return error.InvalidAuthDocument; + } + if (try keychain.load_api_key(alloc)) |key| { + defer secret.zeroAndFree(alloc, key); + if (document.get(.stored_key) == null) { + const next = try document.replaced(alloc, .stored_key, key); + document.deinit(alloc); + document = next; + found = true; + needs_migration = true; + } + } + return if (found) + .{ + .state = if (current != null and !needs_migration) .current else .legacy, + .document = document, + } + else blk: { + document.deinit(alloc); + break :blk .{ .state = .empty }; + }; +} + +fn merge_auth_documents( + alloc: Allocator, + primary: *const auth_store.Document, + fallback: *const auth_store.Document, +) !auth_store.Document { + var merged: auth_store.Document = .{}; + errdefer merged.deinit(alloc); + for (std.meta.tags(auth_store.StoredSource)) |source| { + const value = primary.get(source) orelse fallback.get(source) orelse continue; + const next = try merged.replaced(alloc, source, value); + merged.deinit(alloc); + merged = next; + } + return merged; +} + +fn commit_and_verify_keychain( + alloc: Allocator, + document: auth_store.Document, + keychain: KeychainBackend, +) !void { + const bytes = try document.stringify(alloc); + defer secret.zeroAndFree(alloc, bytes); + try keychain.store_document(bytes); + const persisted = (try keychain.load_document(alloc)) orelse return error.AuthDocumentWriteMismatch; + defer secret.zeroAndFree(alloc, persisted); + var verified = try auth_store.Document.parse(alloc, persisted); + defer verified.deinit(alloc); + if (!document.eql(verified)) return error.AuthDocumentWriteMismatch; +} + +fn publish_keychain_migration( + alloc: Allocator, + fx_dir: *std.Io.Dir, + document: auth_store.Document, + keychain: KeychainBackend, +) bool { + commit_and_verify_keychain(alloc, document, keychain) catch |err| { + debug_trace.logf("auth", "common auth migration deferred backend=keychain step=publish err={s}", .{@errorName(err)}); + return false; + }; + delete_keychain_legacy_profile_files(fx_dir) catch |err| { + debug_trace.logf("auth", "common auth migration cleanup incomplete backend=keychain source=profile err={s}", .{@errorName(err)}); + }; + _ = keychain.delete_api_key(alloc) catch |err| failed: { + debug_trace.logf("auth", "common auth migration cleanup incomplete backend=keychain source=stored_key err={s}", .{@errorName(err)}); + break :failed false; + }; + return true; +} + +fn observe_profile(alloc: Allocator, fx_dir: *std.Io.Dir) !ProfileObservation { + const auth_bytes = try read_optional_private_file( + alloc, + fx_dir, + profile_paths.auth_file_name, + max_auth_document_bytes, + ); + defer if (auth_bytes) |bytes| secret.zeroAndFree(alloc, bytes); + if (auth_bytes) |bytes| { + const version = (try declared_version(alloc, bytes)) orelse return error.InvalidAuthDocument; + if (version == 2) { + return .{ .state = .current, .document = try auth_store.Document.parse(alloc, bytes) }; + } + if (version != 1) return error.InvalidAuthDocument; + } + + var document: auth_store.Document = .{}; + errdefer document.deinit(alloc); + var found_legacy = false; + if (auth_bytes) |bytes| { + if ((try declared_version(alloc, bytes)) == 1) { + const next = try document.replaced(alloc, .fx_login, bytes); + document.deinit(alloc); + document = next; + found_legacy = true; + } + } + if (try read_optional_private_file( + alloc, + fx_dir, + profile_paths.api_key_file_name, + max_legacy_api_key_bytes, + )) |raw_key| { + defer secret.zeroAndFree(alloc, raw_key); + const key = std.mem.trim(u8, raw_key, "\r\n"); + if (key.len > 0) { + const next = try document.replaced(alloc, .stored_key, key); + document.deinit(alloc); + document = next; + found_legacy = true; + } + } + if (try read_optional_private_file( + alloc, + fx_dir, + profile_paths.chatgpt_auth_file_name, + max_legacy_session_bytes, + )) |bytes| { + defer secret.zeroAndFree(alloc, bytes); + if ((try declared_version(alloc, bytes)) == 1) { + const next = try document.replaced(alloc, .chatgpt_subscription, bytes); + document.deinit(alloc); + document = next; + found_legacy = true; + } + } + if (try read_optional_private_file( + alloc, + fx_dir, + profile_paths.grok_auth_file_name, + max_legacy_session_bytes, + )) |bytes| { + defer secret.zeroAndFree(alloc, bytes); + if ((try declared_version(alloc, bytes)) == 1) { + const next = try document.replaced(alloc, .grok_subscription, bytes); + document.deinit(alloc); + document = next; + found_legacy = true; + } + } + + return if (found_legacy) + .{ .state = .legacy, .document = document } + else blk: { + document.deinit(alloc); + break :blk .{ .state = .empty }; + }; +} + +fn declared_version(alloc: Allocator, bytes: []const u8) !?i64 { + var parsed = std.json.parseFromSlice(std.json.Value, alloc, bytes, .{}) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return null, + }; + defer parsed.deinit(); + if (parsed.value != .object) return null; + const version = parsed.value.object.get("version") orelse return null; + return if (version == .integer) version.integer else null; +} + +fn read_optional_private_file( + alloc: Allocator, + dir: *std.Io.Dir, + name: []const u8, + max_bytes: usize, +) !?[]u8 { + var file = dir.openFile(io_mod.getIo(), name, .{ + .mode = .read_only, + .allow_directory = false, + .follow_symlinks = false, + .resolve_beneath = true, + }) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + defer file.close(io_mod.getIo()); + const stat = try file.stat(io_mod.getIo()); + if (stat.kind != .file or stat.nlink != 1 or stat.permissions.toMode() & 0o077 != 0) { + return error.AuthDocumentInsecure; + } + return try io_mod.readFileToEnd(alloc, &file, max_bytes); +} + +fn read_profile_file(alloc: Allocator, home: []const u8, name: []const u8) ![]u8 { + var home_dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }); + defer home_dir.close(io_mod.getIo()); + var fx_dir = try home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ + .iterate = true, + .follow_symlinks = false, + }); + defer fx_dir.close(io_mod.getIo()); + return (try read_optional_private_file(alloc, &fx_dir, name, max_auth_document_bytes)) orelse + error.FileNotFound; +} + +fn delete_legacy_profile_files(fx_dir: *std.Io.Dir) !void { + var deleted = false; + for ([_][]const u8{ + profile_paths.api_key_file_name, + profile_paths.chatgpt_auth_file_name, + profile_paths.grok_auth_file_name, + }) |name| { + fx_dir.deleteFile(io_mod.getIo(), name) catch |err| switch (err) { + error.FileNotFound => continue, + else => return err, + }; + deleted = true; + } + if (deleted) try io_mod.syncVerifiedDir(fx_dir.*); +} + +fn delete_keychain_legacy_profile_files(fx_dir: *std.Io.Dir) !void { + var deleted = false; + for ([_][]const u8{ + profile_paths.auth_file_name, + profile_paths.api_key_file_name, + profile_paths.chatgpt_auth_file_name, + profile_paths.grok_auth_file_name, + }) |name| { + fx_dir.deleteFile(io_mod.getIo(), name) catch |err| switch (err) { + error.FileNotFound => continue, + else => return err, + }; + deleted = true; + } + if (deleted) try io_mod.syncVerifiedDir(fx_dir.*); +} + +test "profile auth store commits one private document and reloads every source" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + var document: auth_store.Document = .{}; + defer document.deinit(alloc); + const with_key = try document.replaced(alloc, .stored_key, "gateway-secret"); + document.deinit(alloc); + document = with_key; + const with_codex = try document.replaced( + alloc, + .chatgpt_subscription, + "{\"version\":1,\"access_token\":\"codex\"}", + ); + document.deinit(alloc); + document = with_codex; + + var mutation = try begin_profile_mutation(home); + try mutation.commit(alloc, document); + mutation.deinit(); + + var fx_dir = try tmp.dir.openDir(std.testing.io, ".fx", .{}); + defer fx_dir.close(std.testing.io); + const stat = try fx_dir.statFile(std.testing.io, "auth.json", .{}); + try std.testing.expectEqual(@as(std.posix.mode_t, 0o600), stat.permissions.toMode() & 0o777); + + var reopened = try begin_profile_mutation(home); + defer reopened.deinit(); + var loaded = (try reopened.load(alloc)) orelse return error.TestExpectedAuthDocument; + defer loaded.deinit(alloc); + try std.testing.expect(document.eql(loaded)); +} + +test "empty active load stays read only" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + try std.testing.expect((try load_profile_document(alloc, home, .active)) == null); + try std.testing.expectError( + error.FileNotFound, + tmp.dir.statFile(std.testing.io, profile_paths.root_dir_name, .{}), + ); +} + +test "malformed common document blocks legacy fallback and migration" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + var verified_home = io_mod.VerifiedDir{ + .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), + }; + defer verified_home.close(); + var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&verified_home, profile_paths.root_dir_name); + defer fx_dir.close(); + try io_mod.durableReplaceVerified( + alloc, + &fx_dir, + profile_paths.auth_file_name, + "{\"version\":2,\"credentials\":[]}", + ); + try io_mod.durableReplaceVerified( + alloc, + &fx_dir, + profile_paths.api_key_file_name, + "gateway-secret", + ); + + try std.testing.expectError( + error.InvalidAuthDocument, + load_profile_document(alloc, home, .active), + ); + _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.api_key_file_name, .{}); +} + +test "legacy inspection is read only and active load publishes one common document" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + var verified_home = io_mod.VerifiedDir{ + .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), + }; + defer verified_home.close(); + var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&verified_home, profile_paths.root_dir_name); + defer fx_dir.close(); + + const vercel = + "{\"version\":1,\"issuer\":\"https://vercel.com\",\"client_id\":\"client\",\"access_token\":\"vercel-access\",\"refresh_token\":\"vercel-refresh\",\"expires_at_ms\":4102444800000,\"scope\":\"openid offline_access\",\"token_type\":\"Bearer\",\"team_slug\":\"team-slug\",\"team_id\":\"team-id\"}"; + const codex = + "{\"version\":1,\"access_token\":\"codex-access\",\"refresh_token\":\"codex-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"codex-account\"}"; + const grok = + "{\"version\":1,\"access_token\":\"grok-access\",\"refresh_token\":\"grok-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"grok-account\"}"; + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.auth_file_name, vercel); + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.api_key_file_name, "gateway-secret"); + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.chatgpt_auth_file_name, codex); + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.grok_auth_file_name, grok); + + var inspected = (try load_profile_document(alloc, home, .inspect)) orelse + return error.TestExpectedLegacyDocument; + defer inspected.deinit(alloc); + try std.testing.expectEqualStrings("gateway-secret", inspected.get(.stored_key).?); + try std.testing.expectEqualStrings(vercel, inspected.get(.fx_login).?); + try std.testing.expectEqualStrings(codex, inspected.get(.chatgpt_subscription).?); + try std.testing.expectEqualStrings(grok, inspected.get(.grok_subscription).?); + _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.api_key_file_name, .{}); + _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.chatgpt_auth_file_name, .{}); + _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.grok_auth_file_name, .{}); + + const before = try read_profile_file(alloc, home, profile_paths.auth_file_name); + defer secret.zeroAndFree(alloc, before); + try std.testing.expect(std.mem.find(u8, before, "\"version\":1") != null); + + var migrated = (try load_profile_document(alloc, home, .active)) orelse + return error.TestExpectedMigratedDocument; + defer migrated.deinit(alloc); + try std.testing.expect(inspected.eql(migrated)); + + const after = try read_profile_file(alloc, home, profile_paths.auth_file_name); + defer secret.zeroAndFree(alloc, after); + var parsed_after = try auth_store.Document.parse(alloc, after); + defer parsed_after.deinit(alloc); + try std.testing.expect(migrated.eql(parsed_after)); + + var migrated_fx_dir = try tmp.dir.openDir(std.testing.io, ".fx", .{}); + defer migrated_fx_dir.close(std.testing.io); + for ([_][]const u8{ + profile_paths.api_key_file_name, + profile_paths.chatgpt_auth_file_name, + profile_paths.grok_auth_file_name, + }) |name| { + try std.testing.expectError( + error.FileNotFound, + migrated_fx_dir.statFile(std.testing.io, name, .{}), + ); + } +} + +test "source mutations share one document without losing unrelated credentials" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + var key_mutation = try begin_profile_entry_mutation(home, .stored_key); + try key_mutation.save(alloc, "gateway-secret"); + key_mutation.deinit(); + + var codex_mutation = try begin_profile_entry_mutation(home, .chatgpt_subscription); + try codex_mutation.save(alloc, "{\"version\":1,\"access_token\":\"codex\"}"); + codex_mutation.deinit(); + + const key = (try load_profile_entry(alloc, home, .stored_key, .inspect)) orelse + return error.TestExpectedStoredKey; + defer secret.zeroAndFree(alloc, key); + try std.testing.expectEqualStrings("gateway-secret", key); + + var delete_codex = try begin_profile_entry_mutation(home, .chatgpt_subscription); + try std.testing.expectEqual(DeleteOutcome.deleted, try delete_codex.delete(alloc)); + delete_codex.deinit(); + + const preserved_key = (try load_profile_entry(alloc, home, .stored_key, .inspect)) orelse + return error.TestExpectedPreservedKey; + defer secret.zeroAndFree(alloc, preserved_key); + try std.testing.expectEqualStrings("gateway-secret", preserved_key); + try std.testing.expect((try load_profile_entry( + alloc, + home, + .chatgpt_subscription, + .inspect, + )) == null); +} + +test "Keychain mutations merge current portable credentials before cleanup" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + var verified_home = io_mod.VerifiedDir{ + .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), + }; + defer verified_home.close(); + var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir( + &verified_home, + profile_paths.root_dir_name, + ); + defer fx_dir.close(); + try io_mod.durableReplaceVerified( + alloc, + &fx_dir, + profile_paths.auth_file_name, + "{\"version\":2,\"credentials\":{\"chatgpt_subscription\":{\"session\":{\"version\":1,\"access_token\":\"portable-codex\"}}}}\n", + ); + + var fake = FakeKeychain{ + .alloc = alloc, + .document = try alloc.dupe( + u8, + "{\"version\":2,\"credentials\":{\"fx_login\":{\"session\":{\"version\":1,\"access_token\":\"keychain-vercel\"}}}}\n", + ), + }; + defer fake.deinit(); + { + var mutation = try begin_profile_entry_mutation(home, .stored_key); + defer mutation.deinit(); + mutation.backend = .macos_keychain; + mutation.keychain = fake.backend(); + try mutation.save(alloc, "new-gateway-key"); + } + + var stored = try auth_store.Document.parse(alloc, fake.document.?); + defer stored.deinit(alloc); + try std.testing.expectEqualStrings( + "{\"version\":1,\"access_token\":\"keychain-vercel\"}", + stored.get(.fx_login).?, + ); + try std.testing.expectEqualStrings( + "{\"version\":1,\"access_token\":\"portable-codex\"}", + stored.get(.chatgpt_subscription) orelse + return error.TestExpectedPortableCredential, + ); + try std.testing.expectEqualStrings( + "new-gateway-key", + stored.get(.stored_key).?, + ); + try std.testing.expectError( + error.FileNotFound, + fx_dir.dir.statFile(std.testing.io, profile_paths.auth_file_name, .{}), + ); + + try io_mod.durableReplaceVerified( + alloc, + &fx_dir, + profile_paths.auth_file_name, + "{\"version\":2,\"credentials\":{\"grok_subscription\":{\"session\":{\"version\":1,\"access_token\":\"portable-grok\"}}}}\n", + ); + var logout = try begin_profile_entry_mutation(home, .fx_login); + defer logout.deinit(); + logout.backend = .macos_keychain; + logout.keychain = fake.backend(); + try std.testing.expectEqual(DeleteOutcome.deleted, try logout.delete(alloc)); + + var after_logout = try auth_store.Document.parse(alloc, fake.document.?); + defer after_logout.deinit(alloc); + try std.testing.expect(after_logout.get(.fx_login) == null); + try std.testing.expectEqualStrings( + "{\"version\":1,\"access_token\":\"portable-codex\"}", + after_logout.get(.chatgpt_subscription).?, + ); + try std.testing.expectEqualStrings( + "{\"version\":1,\"access_token\":\"portable-grok\"}", + after_logout.get(.grok_subscription).?, + ); + try std.testing.expectEqualStrings( + "new-gateway-key", + after_logout.get(.stored_key).?, + ); + try std.testing.expectError( + error.FileNotFound, + fx_dir.dir.statFile(std.testing.io, profile_paths.auth_file_name, .{}), + ); +} + +test "active entry mutation migrates an unexpired legacy subscription" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + var verified_home = io_mod.VerifiedDir{ + .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), + }; + defer verified_home.close(); + var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&verified_home, profile_paths.root_dir_name); + defer fx_dir.close(); + const codex = + "{\"version\":1,\"access_token\":\"codex-access\",\"refresh_token\":\"codex-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"codex-account\"}"; + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.chatgpt_auth_file_name, codex); + + var mutation = try begin_profile_entry_mutation(home, .chatgpt_subscription); + defer mutation.deinit(); + const loaded = (try mutation.load(alloc)) orelse return error.TestExpectedLegacyCredential; + defer secret.zeroAndFree(alloc, loaded); + + try std.testing.expectEqualStrings(codex, loaded); + const persisted = try read_profile_file(alloc, home, profile_paths.auth_file_name); + defer secret.zeroAndFree(alloc, persisted); + var document = try auth_store.Document.parse(alloc, persisted); + defer document.deinit(alloc); + try std.testing.expectEqualStrings(codex, document.get(.chatgpt_subscription).?); + try std.testing.expectError( + error.FileNotFound, + fx_dir.dir.statFile(std.testing.io, profile_paths.chatgpt_auth_file_name, .{}), + ); +} + +test "Keychain migration publishes every source before removing legacy credentials" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + var verified_home = io_mod.VerifiedDir{ + .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), + }; + defer verified_home.close(); + var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&verified_home, profile_paths.root_dir_name); + defer fx_dir.close(); + const codex = + "{\"version\":1,\"access_token\":\"codex-access\",\"refresh_token\":\"codex-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"codex-account\"}"; + const grok = + "{\"version\":1,\"access_token\":\"grok-access\",\"refresh_token\":\"grok-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"grok-account\"}"; + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.chatgpt_auth_file_name, codex); + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.grok_auth_file_name, grok); + + var fake = FakeKeychain{ + .alloc = alloc, + .document = try alloc.dupe( + u8, + "{\"version\":1,\"issuer\":\"https://vercel.com\",\"client_id\":\"client\",\"access_token\":\"vercel-access\",\"refresh_token\":\"vercel-refresh\",\"expires_at_ms\":4102444800000,\"scope\":\"openid offline_access\",\"token_type\":\"Bearer\",\"team_slug\":\"team-slug\",\"team_id\":\"team-id\"}", + ), + .api_key = try alloc.dupe(u8, "gateway-secret"), + }; + defer fake.deinit(); + + var loaded = (try load_keychain_document(alloc, home, .active, fake.backend())) orelse + return error.TestExpectedKeychainDocument; + defer loaded.deinit(alloc); + try std.testing.expect(loaded.get(.stored_key) != null); + try std.testing.expect(loaded.get(.fx_login) != null); + try std.testing.expect(loaded.get(.chatgpt_subscription) != null); + try std.testing.expect(loaded.get(.grok_subscription) != null); + try std.testing.expect(fake.api_key == null); + + var stored = try auth_store.Document.parse(alloc, fake.document.?); + defer stored.deinit(alloc); + try std.testing.expect(loaded.eql(stored)); +} + +test "Keychain publication failure keeps legacy credentials authoritative" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + var verified_home = io_mod.VerifiedDir{ + .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), + }; + defer verified_home.close(); + var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&verified_home, profile_paths.root_dir_name); + defer fx_dir.close(); + const codex = + "{\"version\":1,\"access_token\":\"codex-access\",\"refresh_token\":\"codex-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"codex-account\"}"; + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.chatgpt_auth_file_name, codex); + + var fake = FakeKeychain{ .alloc = alloc, .fail_store = true }; + defer fake.deinit(); + var mutation = try begin_profile_entry_mutation(home, .chatgpt_subscription); + defer mutation.deinit(); + mutation.backend = .macos_keychain; + mutation.keychain = fake.backend(); + const loaded = (try mutation.load(alloc)) orelse + return error.TestExpectedLegacyDocument; + defer secret.zeroAndFree(alloc, loaded); + + try std.testing.expectEqualStrings(codex, loaded); + try std.testing.expect(fake.document == null); + _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.chatgpt_auth_file_name, .{}); +} + +const FakeKeychain = struct { + alloc: Allocator, + document: ?[]u8 = null, + api_key: ?[]u8 = null, + fail_store: bool = false, + + fn deinit(self: *FakeKeychain) void { + if (self.document) |value| secret.zeroAndFree(self.alloc, value); + if (self.api_key) |value| secret.zeroAndFree(self.alloc, value); + self.* = undefined; + } + + fn backend(self: *FakeKeychain) KeychainBackend { + return .{ + .context = self, + .load_document_fn = load_document, + .store_document_fn = store_document, + .load_api_key_fn = load_api_key, + .delete_api_key_fn = delete_api_key, + }; + } + + fn load_document(raw: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { + const self: *FakeKeychain = @ptrCast(@alignCast(raw.?)); + const value = self.document orelse return null; + return try alloc.dupe(u8, value); + } + + fn store_document(raw: ?*anyopaque, value: []const u8) KeychainError!void { + const self: *FakeKeychain = @ptrCast(@alignCast(raw.?)); + if (self.fail_store) return error.KeychainWriteFailed; + const replacement = try self.alloc.dupe(u8, value); + if (self.document) |old| secret.zeroAndFree(self.alloc, old); + self.document = replacement; + } + + fn load_api_key(raw: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { + const self: *FakeKeychain = @ptrCast(@alignCast(raw.?)); + const value = self.api_key orelse return null; + return try alloc.dupe(u8, value); + } + + fn delete_api_key(raw: ?*anyopaque, _: Allocator) KeychainError!bool { + const self: *FakeKeychain = @ptrCast(@alignCast(raw.?)); + const value = self.api_key orelse return false; + secret.zeroAndFree(self.alloc, value); + self.api_key = null; + return true; + } +}; diff --git a/src/core/hosts/native_keychain.zig b/src/core/hosts/native_keychain.zig index e894dccbc..72d743d2d 100644 --- a/src/core/hosts/native_keychain.zig +++ b/src/core/hosts/native_keychain.zig @@ -13,7 +13,7 @@ pub const AccountBuffer = [256]u8; const passwd_scratch_bytes = 2048; const max_mcp_credentials_bytes: usize = 1024 * 1024; -const max_oauth_session_bytes: usize = 64 * 1024; +const max_oauth_session_bytes: usize = 256 * 1024; const keychain_process_timeout: std.Io.Timeout = .{ .duration = .{ .raw = .{ .nanoseconds = 10 * std.time.ns_per_s }, @@ -355,6 +355,10 @@ pub fn deleteMcpCredentials(alloc: std.mem.Allocator) Error!bool { return deleteMcpValueMac(alloc, mcp_credentials_service_name); } +pub fn delete(alloc: std.mem.Allocator) Error!bool { + return deleteServiceItem(alloc, service_name); +} + pub fn deleteOAuthSession(alloc: std.mem.Allocator) Error!bool { return deleteMcpValueMac(alloc, oauth_session_service_name); } diff --git a/src/core/hosts/native_secret_store.zig b/src/core/hosts/native_secret_store.zig index d8b8719fe..77daba893 100644 --- a/src/core/hosts/native_secret_store.zig +++ b/src/core/hosts/native_secret_store.zig @@ -1,10 +1,10 @@ const std = @import("std"); const builtin = @import("builtin"); +const auth_store = @import("../auth/auth_store.zig"); const debug_trace = @import("../shared/debug_trace.zig"); const host = @import("host.zig"); -const io_mod = @import("../shared/io.zig"); const keychain = @import("native_keychain.zig"); -const profile_paths = @import("../shared/profile_paths.zig"); +const native_auth_store = @import("native_auth_store.zig"); const secret = @import("../auth/secret.zig"); const Allocator = std.mem.Allocator; @@ -13,8 +13,6 @@ const Allocator = std.mem.Allocator; /// stored key lives without knowing how the backend is selected. const backend_label = if (builtin.os.tag == .macos) "macOS Keychain" else "profile file"; -const max_key_file_bytes: usize = 8 * 1024; - const LoadError = host.SecretStoreLoadError; const StoreError = host.SecretStoreWriteError; @@ -22,6 +20,7 @@ pub const provider: host.SecretStore = .{ .backend_label = backend_label, .is_disabled_fn = isDisabledCallback, .load_fn = loadCallback, + .load_stored_fn = loadStoredCallback, .store_fn = storeCallback, .store_interactive_fn = storeInteractiveCallback, }; @@ -34,17 +33,31 @@ fn isDisabled() bool { /// Returns the stored key, or null when no key is stored. An error means the store /// could not be read, which callers must keep distinct from absence. fn load(alloc: Allocator) LoadError!?[]u8 { - if (comptime builtin.os.tag == .macos) return loadFromKeychain(alloc); - return loadFromProfile(alloc); + return loadWithIntent(alloc, .active); +} + +fn loadStored(alloc: Allocator) LoadError!?[]u8 { + return loadWithIntent(alloc, .inspect); +} + +fn loadWithIntent( + alloc: Allocator, + intent: auth_store.LoadIntent, +) LoadError!?[]u8 { + return native_auth_store.load_entry(alloc, .stored_key, intent) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.AuthDocumentInsecure => error.StoredKeyInsecure, + else => error.StoredKeyUnreadable, + }; } fn store(alloc: Allocator, value: []const u8) StoreError!void { if (value.len == 0) return error.StoredKeyWriteFailed; - if (comptime builtin.os.tag == .macos) { - keychain.storeValue(value) catch |err| return writeFailed("keychain", err); - return; - } - return storeInProfile(alloc, value); + var mutation = native_auth_store.begin_entry_mutation(.stored_key) catch |err| { + return writeFailed("begin_auth_store", err); + }; + defer mutation.deinit(); + mutation.save(alloc, value) catch |err| return writeFailed("commit_auth_store", err); } /// Let the platform credential store own terminal input when it supports a @@ -52,6 +65,15 @@ fn store(alloc: Allocator, value: []const u8) StoreError!void { fn storeInteractive() StoreError!bool { if (comptime builtin.os.tag == .macos) { keychain.storeInteractive() catch |err| return writeFailed("keychain_interactive", err); + const alloc = std.heap.c_allocator; + const value = (keychain.load(alloc) catch |err| return writeFailed("keychain_interactive_readback", err)) orelse + return writeFailed("keychain_interactive_readback", error.KeychainItemNotFound); + defer secret.zeroAndFree(alloc, value); + var mutation = native_auth_store.begin_entry_mutation(.stored_key) catch |err| { + return writeFailed("keychain_interactive_begin", err); + }; + defer mutation.deinit(); + mutation.save(alloc, value) catch |err| return writeFailed("keychain_interactive_commit", err); return true; } return false; @@ -65,6 +87,10 @@ fn loadCallback(_: ?*anyopaque, alloc: Allocator) LoadError!?[]u8 { return load(alloc); } +fn loadStoredCallback(_: ?*anyopaque, alloc: Allocator) LoadError!?[]u8 { + return loadStored(alloc); +} + fn storeCallback( _: ?*anyopaque, alloc: Allocator, @@ -77,109 +103,6 @@ fn storeInteractiveCallback(_: ?*anyopaque) StoreError!bool { return storeInteractive(); } -fn loadFromKeychain(alloc: Allocator) LoadError!?[]u8 { - return keychain.load(alloc) catch |err| switch (err) { - error.OutOfMemory => error.OutOfMemory, - error.KeychainItemNotFound => null, - else => error.StoredKeyUnreadable, - }; -} - -fn loadFromProfile(alloc: Allocator) LoadError!?[]u8 { - const home = io_mod.getenv("HOME") orelse { - debug_trace.logf("stored_key", "load failed step=home err=HomeNotSet", .{}); - return error.StoredKeyUnreadable; - }; - var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| { - debug_trace.logf("stored_key", "load failed step=open_home err={s}", .{@errorName(err)}); - return error.StoredKeyUnreadable; - }; - defer home_dir.close(io_mod.getIo()); - - var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }) catch |err| switch (err) { - error.FileNotFound => return null, - else => { - debug_trace.logf("stored_key", "load failed step=open_profile err={s}", .{@errorName(err)}); - return error.StoredKeyUnreadable; - }, - }; - defer fx_dir.close(io_mod.getIo()); - - return loadFromDir(alloc, &fx_dir); -} - -fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir) LoadError!?[]u8 { - var file = fx_dir.openFile(io_mod.getIo(), profile_paths.api_key_file_name, .{ - .mode = .read_only, - .allow_directory = false, - .follow_symlinks = false, - .resolve_beneath = true, - }) catch |err| switch (err) { - error.FileNotFound => return null, - else => { - debug_trace.logf("stored_key", "load failed step=open_file err={s}", .{@errorName(err)}); - return error.StoredKeyUnreadable; - }, - }; - defer file.close(io_mod.getIo()); - - const stat = file.stat(io_mod.getIo()) catch |err| { - debug_trace.logf("stored_key", "load failed step=stat err={s}", .{@errorName(err)}); - return error.StoredKeyUnreadable; - }; - if (stat.kind != .file or stat.permissions.toMode() & 0o077 != 0) { - debug_trace.logf("stored_key", "load failed step=permissions err=StoredKeyInsecure", .{}); - return error.StoredKeyInsecure; - } - - const bytes = io_mod.readFileToEnd(alloc, &file, max_key_file_bytes) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => { - debug_trace.logf("stored_key", "load failed step=read err={s}", .{@errorName(err)}); - return error.StoredKeyUnreadable; - }, - }; - var borrowed = false; - defer if (!borrowed) secret.zeroAndFree(alloc, bytes); - - const trimmed = std.mem.trim(u8, bytes, "\r\n"); - if (trimmed.len == 0) return null; - if (trimmed.len == bytes.len) { - borrowed = true; - return bytes; - } - return try alloc.dupe(u8, trimmed); -} - -fn storeInProfile(alloc: Allocator, value: []const u8) StoreError!void { - const home = io_mod.getenv("HOME") orelse return writeFailed("home", error.HomeNotSet); - var home_dir = io_mod.VerifiedDir{ - .dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| { - return writeFailed("open_home", err); - }, - }; - defer home_dir.close(); - - var fx_dir = io_mod.openOrCreateVerifiedPrivateDir(&home_dir, profile_paths.root_dir_name) catch |err| { - return writeFailed("open_profile", err); - }; - defer fx_dir.close(); - - return storeInDir(alloc, &fx_dir, value); -} - -/// `durableReplaceVerified` creates the file at 0600 and re-stats it after the rename, -/// so the mode this store depends on is enforced rather than assumed. -fn storeInDir(alloc: Allocator, fx_dir: *io_mod.VerifiedDir, value: []const u8) StoreError!void { - io_mod.durableReplaceVerified(alloc, fx_dir, profile_paths.api_key_file_name, value) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => return writeFailed("replace", err), - }; -} - fn writeFailed(step: []const u8, err: anyerror) StoreError { debug_trace.logf("stored_key", "store failed step={s} err={s}", .{ step, @errorName(err) }); return error.StoredKeyWriteFailed; @@ -194,68 +117,6 @@ test "stored key backend label names the platform store" { try std.testing.expectEqualStrings(backend_label, provider.backend_label); } -test "stored key file round-trips byte-identically at mode 0600" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var fx_dir = io_mod.VerifiedDir{ - .dir = try tmp.dir.openDir(io_mod.getIo(), ".", .{ .iterate = true, .follow_symlinks = false }), - }; - defer fx_dir.close(); - - const written = "vt1-file-round-trip-value"; - try storeInDir(std.testing.allocator, &fx_dir, written); - - const stat = try tmp.dir.statFile(std.testing.io, profile_paths.api_key_file_name, .{}); - try std.testing.expect(stat.permissions.toMode() & 0o777 == 0o600); - - const read_back = (try loadFromDir(std.testing.allocator, &fx_dir.dir)) orelse - return error.TestUnexpectedMissingStoredKey; - defer secret.zeroAndFree(std.testing.allocator, read_back); - try std.testing.expectEqualStrings(written, read_back); -} - -test "stored key file refusal stays distinguishable from absence" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var fx_dir = io_mod.VerifiedDir{ - .dir = try tmp.dir.openDir(io_mod.getIo(), ".", .{ .iterate = true, .follow_symlinks = false }), - }; - defer fx_dir.close(); - - try std.testing.expect((try loadFromDir(std.testing.allocator, &fx_dir.dir)) == null); - - try storeInDir(std.testing.allocator, &fx_dir, "vt2-secret-value"); - for ([_]std.posix.mode_t{ 0o640, 0o604, 0o644 }) |mode| { - var file = try tmp.dir.openFile(std.testing.io, profile_paths.api_key_file_name, .{ .mode = .read_write }); - try file.setPermissions(std.testing.io, std.Io.File.Permissions.fromMode(mode)); - file.close(std.testing.io); - - try std.testing.expectError( - error.StoredKeyInsecure, - loadFromDir(std.testing.allocator, &fx_dir.dir), - ); - } - - try tmp.dir.deleteFile(std.testing.io, profile_paths.api_key_file_name); - try std.testing.expect((try loadFromDir(std.testing.allocator, &fx_dir.dir)) == null); -} - -test "stored key file tolerates a trailing newline and rejects an empty value" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var fx_dir = io_mod.VerifiedDir{ - .dir = try tmp.dir.openDir(io_mod.getIo(), ".", .{ .iterate = true, .follow_symlinks = false }), - }; - defer fx_dir.close(); - - try storeInDir(std.testing.allocator, &fx_dir, "hand-edited-value\n"); - const read_back = (try loadFromDir(std.testing.allocator, &fx_dir.dir)) orelse - return error.TestUnexpectedMissingStoredKey; - defer secret.zeroAndFree(std.testing.allocator, read_back); - try std.testing.expectEqualStrings("hand-edited-value", read_back); - - try storeInDir(std.testing.allocator, &fx_dir, "\n\n"); - try std.testing.expect((try loadFromDir(std.testing.allocator, &fx_dir.dir)) == null); - +test "stored key rejects an empty value" { try std.testing.expectError(error.StoredKeyWriteFailed, store(std.testing.allocator, "")); } diff --git a/tests/e2e/acp.test.ts b/tests/e2e/acp.test.ts index 6515e43ee..d50c285c4 100644 --- a/tests/e2e/acp.test.ts +++ b/tests/e2e/acp.test.ts @@ -199,6 +199,7 @@ function fakeGatewayEnv( FX_GATEWAY_CHAT_URL: gateway.chatUrl, FX_MODEL: FAKE_GATEWAY_MODEL, FX_AUTO_UPGRADE: "0", + FX_DISABLE_KEYCHAIN: "1", }; } @@ -8644,6 +8645,14 @@ describe.skipIf(!HAS_API_KEY)("acp: model-backed protocol", () => { const prompt = await runPrompt(client, "Answer directly.", TIMEOUT); expect(prompt.promptResult.result.stopReason).toBe("end_turn"); expect(JSON.stringify(prompt.messages)).toContain("ACP_CHATGPT_RESPONSE"); + const commonAuth = JSON.parse( + readFileSync(join(root.home, ".fx", "auth.json"), "utf8"), + ); + expect(commonAuth.version).toBe(2); + expect(commonAuth.credentials.chatgpt_subscription.session.account_id).toBe( + "acct_acp_e2e", + ); + expect(existsSync(join(root.home, ".fx", "chatgpt-auth.json"))).toBe(false); const secondPrompt = await runPrompt(client, "Answer again.", TIMEOUT); expect(secondPrompt.promptResult.result.stopReason).toBe("end_turn"); expect(codex.requests).toHaveLength(3); @@ -8700,6 +8709,14 @@ describe.skipIf(!HAS_API_KEY)("acp: model-backed protocol", () => { const prompt = await runPrompt(client, "Answer directly.", TIMEOUT); expect(prompt.promptResult.result.stopReason).toBe("end_turn"); expect(JSON.stringify(prompt.messages)).toContain("ACP_GROK_RESPONSE"); + const commonAuth = JSON.parse( + readFileSync(join(root.home, ".fx", "auth.json"), "utf8"), + ); + expect(commonAuth.version).toBe(2); + expect(commonAuth.credentials.grok_subscription.session.account_id).toBe( + "acct_grok_acp", + ); + expect(existsSync(join(root.home, ".fx", "grok-auth.json"))).toBe(false); const secondPrompt = await runPrompt(client, "Answer again.", TIMEOUT); expect(secondPrompt.promptResult.result.stopReason).toBe("end_turn"); diff --git a/tests/e2e/auth-refresh.test.ts b/tests/e2e/auth-refresh.test.ts index 0dd55fff1..05372bdbe 100644 --- a/tests/e2e/auth-refresh.test.ts +++ b/tests/e2e/auth-refresh.test.ts @@ -200,7 +200,10 @@ test( ).toBe(0); expect(logoutResult.stdout).toBe("Signed out of fx.\n"); expect(tokenRequestCount).toBe(1); - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); + const afterLogout = JSON.parse( + readFileSync(join(home, ".fx", "auth.json"), "utf8"), + ); + expect(afterLogout.credentials.fx_login).toBeUndefined(); const revocations = oauth.requests.filter( (request) => request.path === "/oauth/revoke", ); @@ -281,7 +284,9 @@ test( const persisted = JSON.parse( readFileSync(join(home, ".fx", "auth.json"), "utf8"), ); - expect(persisted.access_token).toBe(RETRY_REFRESH_TOKEN); + expect(persisted.credentials.fx_login.session.access_token).toBe( + RETRY_REFRESH_TOKEN, + ); expect(result.stdout).not.toContain(EXPIRED_REFRESH_TOKEN); expect(result.stdout).not.toContain(RETRY_REFRESH_TOKEN); expect(result.stderr).not.toContain(EXPIRED_REFRESH_TOKEN); @@ -569,7 +574,7 @@ test( const persisted = JSON.parse( readFileSync(join(home, ".fx", "auth.json"), "utf8"), ); - expect(persisted).toMatchObject({ + expect(persisted.credentials.fx_login.session).toMatchObject({ issuer: issuerA.issuerUrl, access_token: ISSUER_A_ACCESS_TOKEN, refresh_token: "rotated-refresh-token", diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index e221b5bb7..5b71fce1f 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -68,6 +68,20 @@ function doctorSessionDiagnosticsLimit(): number { } const SEEDED_GATEWAY_TOKEN = "seeded-access-token"; +const OAUTH_KEYCHAIN_SERVICE = "FX_OAUTH_SESSION_V1"; + +function commonCredential(home: string, source: string) { + const path = join(home, ".fx", "auth.json"); + if (!existsSync(path)) return undefined; + const parsed = JSON.parse(readFileSync(path, "utf8")); + return parsed.version === 2 ? parsed.credentials?.[source] : parsed; +} + +function fxLoginPresentAtPath(path: string): boolean { + if (!existsSync(path)) return false; + const parsed = JSON.parse(readFileSync(path, "utf8")); + return parsed.version === 2 ? parsed.credentials?.fx_login !== undefined : true; +} function writeSeededFxAuth( home: string, @@ -172,7 +186,7 @@ function startLogoutIssuer( typeof tokenTypeHint === "string" ? tokenTypeHint : "missing", validForm, ...(authPath - ? { localSessionPresent: existsSync(authPath) } + ? { localSessionPresent: fxLoginPresentAtPath(authPath) } : {}), }); const configuredStatus = revokeStatuses[revokeAttempt] ?? 200; @@ -1547,7 +1561,7 @@ describe("cli: logout", () => { expect(logout.code).toBe(0); expect(logout.stdout).toBe("Signed out of fx.\n"); expect(logout.stderr).toBe(""); - expect(existsSync(authPath)).toBe(false); + expect(commonCredential(home, "fx_login")).toBeUndefined(); expect(issuer.requests).toEqual([ { method: "GET", path: "/.well-known/openid-configuration" }, { @@ -1603,7 +1617,7 @@ describe("cli: logout", () => { expect(logout.stderr).toBe( "Warning: signed out locally, but the remote session could not be revoked.\n", ); - expect(existsSync(authPath)).toBe(false); + expect(commonCredential(home, "fx_login")).toBeUndefined(); expect(issuer.requests).toEqual([ { method: "GET", path: "/.well-known/openid-configuration" }, ]); @@ -1639,7 +1653,7 @@ describe("cli: logout", () => { expect(logout.stderr).toBe( "Warning: signed out locally, but the remote session could not be revoked.\n", ); - expect(existsSync(authPath)).toBe(false); + expect(commonCredential(home, "fx_login")).toBeUndefined(); expect(issuer.requests).toEqual([ { method: "GET", path: "/.well-known/openid-configuration" }, ]); @@ -1653,7 +1667,7 @@ describe("cli: logout", () => { ); test( - "fx logout removes a saved login rejected for unsafe permissions", + "fx logout fails closed for a common auth document with unsafe permissions", async () => { const home = mkdtempSync(join(tmpdir(), "fx-e2e-logout-rejected-login-")); const issuer = startLogoutIssuer([200, 200]); @@ -1670,10 +1684,13 @@ describe("cli: logout", () => { }, }); - expect(logout.code).toBe(0); - expect(logout.stdout).toBe("Signed out of fx.\n"); - expect(logout.stderr).toBe(""); - expect(existsSync(authPath)).toBe(false); + expect(logout.code).toBe(1); + expect(logout.stdout).toBe(""); + expect(logout.stderr).toBe( + "fx logout: failed to durably remove saved fx login\n" + + "Warning: signed out locally, but the remote session could not be revoked.\n", + ); + expect(existsSync(authPath)).toBe(true); expect(issuer.requests).toEqual([]); for (const secret of [ SEEDED_GATEWAY_TOKEN, @@ -1700,7 +1717,8 @@ describe("cli: logout", () => { const authPath = join(fxDir, "auth.json"); try { writeSeededFxAuth(home, undefined, issuer.issuerUrl); - chmodSync(fxDir, 0o500); + rmSync(authPath); + mkdirSync(authPath, { mode: 0o700 }); const env = { ...NO_GATEWAY_AUTH, @@ -1713,13 +1731,14 @@ describe("cli: logout", () => { expect(logout.code).toBe(1); expect(logout.stdout).toBe(""); expect(logout.stderr).toBe( - "fx logout: failed to durably remove saved fx login\n", + "fx logout: failed to durably remove saved fx login\n" + + "Warning: signed out locally, but the remote session could not be revoked.\n", ); expect(existsSync(authPath)).toBe(true); - expect(JSON.parse(status.stdout).auth).toBe("fx login"); + expect(JSON.parse(status.stdout).auth).toBe("missing"); expect(issuer.requests).toEqual([]); } finally { - chmodSync(fxDir, 0o700); + rmSync(authPath, { recursive: true, force: true }); issuer.stop(); rmSync(home, { recursive: true, force: true }); } @@ -1750,7 +1769,7 @@ describe("cli: logout", () => { expect(logout.stderr).toBe( "Warning: signed out locally, but the remote session could not be revoked.\n", ); - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); + expect(commonCredential(home, "fx_login")).toBeUndefined(); expect(issuer.requests).toEqual([ { method: "GET", path: "/.well-known/openid-configuration" }, { @@ -1843,7 +1862,7 @@ describe("cli: logout", () => { ); test.skipIf(platform() !== "darwin")( - "fx logout leaves the macOS Keychain API key untouched", + "fx logout preserves the macOS Keychain API key in the common store", async () => { const runId = `${process.pid}-${Date.now()}`; const account = `fx-e2e-logout-${runId}`; @@ -1883,9 +1902,8 @@ describe("cli: logout", () => { expect(logout.code).toBe(0); expect(logout.stderr).toBe(""); expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); - expect(stored.status).toBe(0); - expect(stored.stdout.trim()).toBe(keychainToken); - expect(JSON.parse(status.stdout).auth).not.toBe("fx login"); + expect(stored.status).not.toBe(0); + expect(JSON.parse(status.stdout).auth).toBe("stored API key (macOS Keychain)"); expect(logout.stdout).not.toContain(keychainToken); expect(status.stdout).not.toContain(keychainToken); } finally { @@ -1895,6 +1913,11 @@ describe("cli: logout", () => { ["delete-generic-password", "-a", account, "-s", KEYCHAIN_SERVICE], { encoding: "utf8" }, ); + spawnSync( + "/usr/bin/security", + ["delete-generic-password", "-a", account, "-s", OAUTH_KEYCHAIN_SERVICE], + { encoding: "utf8" }, + ); rmSync(root, { recursive: true, force: true }); } }, @@ -1965,6 +1988,7 @@ describe("cli: stored key file backend", () => { mkdirSync(fxDir, { recursive: true, mode: 0o700 }); chmodSync(fxDir, 0o700); const keyPath = join(fxDir, "api-key"); + const authPath = join(fxDir, "auth.json"); writeFileSync(keyPath, "vca_file_backend_key", { mode: 0o600 }); chmodSync(keyPath, 0o600); const env = { ...NO_GATEWAY_AUTH, HOME: realpathSync(home) }; @@ -1976,6 +2000,8 @@ describe("cli: stored key file backend", () => { expect(readableJson.auth).toBe("stored API key (profile file)"); expect(readableJson.auth_help).toBeUndefined(); expect(readable.stdout).not.toContain("vca_file_backend_key"); + expect(existsSync(keyPath)).toBe(true); + expect(existsSync(authPath)).toBe(false); chmodSync(keyPath, 0o644); const refused = await runFx(["status", "--json"], { env }); @@ -2089,7 +2115,21 @@ describe("cli: Keychain authentication", () => { "Keychain ask complete", ); expect(result.stdout).not.toContain(fakeKey); - expect(existsSync(join(home, ".fx"))).toBe(false); + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); + const legacy = spawnSync( + "/usr/bin/security", + ["find-generic-password", "-a", account, "-s", KEYCHAIN_SERVICE, "-w"], + { encoding: "utf8", env: { ...process.env, HOME: realpathSync(home), USER: account } }, + ); + expect(legacy.status).not.toBe(0); + const status = await runFx(["status", "--json"], { + env: { + ...NO_GATEWAY_AUTH, + HOME: realpathSync(home), + USER: account, + }, + }); + expect(JSON.parse(status.stdout).auth).toBe("stored API key (macOS Keychain)"); expect(gateway.requests).toHaveLength(1); expect(gateway.requests[0]!.headers.get("authorization")).toBe( `Bearer ${fakeKey}`, @@ -2107,6 +2147,17 @@ describe("cli: Keychain authentication", () => { ], { encoding: "utf8" }, ); + spawnSync( + "/usr/bin/security", + [ + "delete-generic-password", + "-a", + account, + "-s", + OAUTH_KEYCHAIN_SERVICE, + ], + { encoding: "utf8" }, + ); rmSync(root, { recursive: true, force: true }); } }, diff --git a/tests/e2e/oauth-keychain-migration.test.ts b/tests/e2e/oauth-keychain-migration.test.ts index 67297126e..a51e04c73 100644 --- a/tests/e2e/oauth-keychain-migration.test.ts +++ b/tests/e2e/oauth-keychain-migration.test.ts @@ -219,14 +219,8 @@ keychainTest( const first = await runFx(["status", "--json"], { env, timeoutMs: TIMEOUT }); expect(first.code, `stdout: ${first.stdout}\nstderr: ${first.stderr}`).toBe(0); expect(JSON.parse(first.stdout).auth).toBe("fx login"); - expect( - existsSync(join(home, ".fx", "auth.json")), - readFileSync(join(home, "oauth-keychain-trace.log"), "utf8"), - ).toBe(false); - - const stored = loadKeychainItem(account, home); - expect(stored).not.toBeNull(); - expect(JSON.parse(stored!).access_token).toBe(`keychain-access-${account}`); + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(true); + expect(loadKeychainItem(account, home)).toBeNull(); const refreshed = await runFx( ["ask", "--json", "--no-save", "Refresh the saved login."], @@ -239,10 +233,17 @@ keychainTest( expect(JSON.parse(refreshed.stdout).output).toContain( "Keychain refresh complete", ); - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); - expect(JSON.parse(loadKeychainItem(account, home)!).access_token).toBe( + expect( + existsSync(join(home, ".fx", "auth.json")), + readFileSync(join(home, "oauth-keychain-trace.log"), "utf8"), + ).toBe(false); + + const stored = loadKeychainItem(account, home); + expect(stored).not.toBeNull(); + expect(JSON.parse(stored!).credentials.fx_login.session.access_token).toBe( "keychain-refreshed-access", ); + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); expect( issuer.requests.filter((request) => request.path === "/oauth/token"), ).toHaveLength(1); @@ -256,7 +257,7 @@ keychainTest( const logout = await runFx(["logout"], { env, timeoutMs: TIMEOUT }); expect(logout.code, `stdout: ${logout.stdout}\nstderr: ${logout.stderr}`).toBe(0); expect(logout.stdout).toBe("Signed out of fx.\n"); - expect(loadKeychainItem(account, home)).toBeNull(); + expect(JSON.parse(loadKeychainItem(account, home)!).credentials.fx_login).toBeUndefined(); expect(issuer.requests.filter((request) => request.path === "/oauth/revoke")).toHaveLength(2); } finally { cleanup(); @@ -279,6 +280,7 @@ keychainTest( const home = mkdtempSync(join(tmpdir(), "fx-oauth-keychain-failure-")); attachSystemKeychain(home); const issuer = startOAuthIssuer(); + const gateway = startFakeGateway([fakeGatewayFinalText("migration complete")]); const cleanup = () => deleteKeychainItem(account); activeCleanups.add(cleanup); cleanup(); @@ -286,8 +288,13 @@ keychainTest( let injectedFailureObserved = false; try { - const status = await runFx(["status", "--json"], { - env: keychainEnv(home, account, issuer.issuer), + const status = await runFx(["ask", "--json", "--no-save", "Migrate auth."], { + env: { + ...keychainEnv(home, account, issuer.issuer), + FX_GATEWAY_BASE_URL: gateway.baseUrl, + FX_GATEWAY_CHAT_URL: gateway.chatUrl, + FX_MODEL: FAKE_GATEWAY_MODEL, + }, timeoutMs: TIMEOUT, }); expect(status.code).toBe(0); @@ -299,6 +306,7 @@ keychainTest( } finally { cleanup(); activeCleanups.delete(cleanup); + gateway.stop(); issuer.stop(); } diff --git a/tests/e2e/tui-auth-source-selection.test.ts b/tests/e2e/tui-auth-source-selection.test.ts index bac87d5b9..a7fd3a8db 100644 --- a/tests/e2e/tui-auth-source-selection.test.ts +++ b/tests/e2e/tui-auth-source-selection.test.ts @@ -165,6 +165,30 @@ function writeSeededGrokLogin(testHome: string, accessToken: string, accountId = chmodSync(authPath, 0o600); } +function readCommonAuth(testHome: string): { + version: number; + credentials: Record }>; +} { + return JSON.parse(readFileSync(join(testHome, ".fx", "auth.json"), "utf8")); +} + +function commonSession(testHome: string, source: "fx_login" | "chatgpt_subscription" | "grok_subscription") { + return readCommonAuth(testHome).credentials[source]?.session; +} + +function persistedSession( + testHome: string, + source: "fx_login" | "chatgpt_subscription" | "grok_subscription", + legacyFile: string, +) { + const commonPath = join(testHome, ".fx", "auth.json"); + if (existsSync(commonPath)) { + const session = commonSession(testHome, source); + if (session) return session; + } + return JSON.parse(readFileSync(join(testHome, ".fx", legacyFile), "utf8")); +} + function readSingleUsageSnapshot(testHome: string): { billing: string; next_sequence: number; @@ -1171,7 +1195,7 @@ tmuxTest( await session.waitForComposer(TIMEOUT); expect(session.isAlive()).toBe(true); - expect(existsSync(join(home, ".fx", "chatgpt-auth.json"))).toBe(false); + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); expect(await session.captureFullScrollback()).not.toContain("Signed in with Codex."); expect(readFileSync(stderrPath, "utf8")).toBe(""); }, @@ -1212,9 +1236,10 @@ tmuxTest( await completeDisplayedCodexLogin(session, chatgptOauth); await session.waitForText("Switched to Codex subscription with gpt-5.6-sol.", TIMEOUT); - const authPath = join(home, ".fx", "chatgpt-auth.json"); + const authPath = join(home, ".fx", "auth.json"); expect(existsSync(authPath)).toBe(true); expect(statSync(authPath).mode & 0o077).toBe(0); + expect(commonSession(home, "chatgpt_subscription")).toBeDefined(); await session.sendText("/status"); await session.waitForText( @@ -1329,7 +1354,7 @@ tmuxTest( .toHaveLength(authorizeRequestsBeforeRoundTrip); await session.sendText("/logout codex"); await session.waitForText("Signed out of Codex.", TIMEOUT); - expect(existsSync(authPath)).toBe(false); + expect(commonSession(home, "chatgpt_subscription")).toBeUndefined(); await session.sendText("/status"); await session.waitForText("model_source=Codex subscription", TIMEOUT); chatgptOauth.setModels([ @@ -1668,9 +1693,10 @@ profileStoredKeyTmuxTest( await session.waitForText("auth=stored API key (profile file)", TIMEOUT); expect(savedCredentialSource(home)).toBe("stored_key"); - const keyPath = join(home, ".fx", "api-key"); - expect(readFileSync(keyPath, "utf8")).toBe(STORED_TOKEN); - expect(statSync(keyPath).mode & 0o777).toBe(0o600); + const authPath = join(home, ".fx", "auth.json"); + expect(readCommonAuth(home).credentials.stored_key?.secret).toBe(STORED_TOKEN); + expect(statSync(authPath).mode & 0o777).toBe(0o600); + expect(existsSync(join(home, ".fx", "api-key"))).toBe(false); await session.kill(); session = await startFx(home, stderrPath, gateway, undefined, undefined, { @@ -1791,7 +1817,7 @@ tmuxTest( await session.waitForText("auth=fx login", TIMEOUT); expect(savedCredentialSource(home)).toBe("fx_login"); - const savedAuth = JSON.parse(readFileSync(join(home, ".fx", "auth.json"), "utf8")) as { + const savedAuth = commonSession(home, "fx_login") as { team_id?: string; team_slug?: string; }; @@ -1855,12 +1881,13 @@ tmuxTest( await session.sendKeys("Enter"); await session.sendText("/status"); await session.waitForText("auth=fx login", TIMEOUT); - expect(readFileSync(authPath, "utf8")).toBe(seededAuthFile); + const migratedAuthFile = readFileSync(authPath, "utf8"); + expect(readCommonAuth(home).version).toBe(2); await session.sendText("use the selected login credential"); await session.waitForText(LOGIN_RESPONSE, TIMEOUT); expect(gateway.requests).toHaveLength(2); expect(gateway.requests[1].headers.get("authorization")).toBe(`Bearer ${LOGIN_TOKEN}`); - expect(readFileSync(authPath, "utf8")).toBe(seededAuthFile); + expect(readFileSync(authPath, "utf8")).toBe(migratedAuthFile); const firstRunOutput = await session.captureFullScrollback(); const firstRunStderr = readFileSync(stderrPath, "utf8"); @@ -1871,7 +1898,7 @@ tmuxTest( // The switch above is remembered, so the restart keeps fx login rather than // letting AI_GATEWAY_API_KEY reclaim it through precedence. await session.waitForText("auth=fx login", TIMEOUT); - expect(readFileSync(authPath, "utf8")).toBe(seededAuthFile); + expect(readFileSync(authPath, "utf8")).toBe(migratedAuthFile); await session.sendText("use the remembered credential after restart"); await session.waitForText(RESTART_RESPONSE, TIMEOUT); expect(gateway.requests).toHaveLength(3); @@ -1893,7 +1920,7 @@ tmuxTest( expect(oauth.requests[3].authorization).toBe(`Bearer ${ACQUIRED_LOGIN_TOKEN}`); expect(oauth.requests[1].clientId).toBe("test-client"); expect(oauth.requests[2].clientId).toBe("test-client"); - const acquiredAuth = JSON.parse(readFileSync(authPath, "utf8")) as { + const acquiredAuth = commonSession(home, "fx_login") as { issuer: string; client_id: string; access_token: string; @@ -1918,7 +1945,7 @@ tmuxTest( await session.sendText("/logout"); const loggedOut = await session.waitForText("Signed out of fx.", TIMEOUT); expect(loggedOut).not.toContain("remote session could not be revoked"); - expect(existsSync(authPath)).toBe(false); + expect(commonSession(home, "fx_login")).toBeUndefined(); expect( oauth.requests .filter((request) => request.path === "/oauth/revoke") @@ -2168,7 +2195,7 @@ test( expect(tokenRequests).toHaveLength(1); expect(tokenRequests[0].clientId).toBe(fallbackClientId); - const persisted = JSON.parse(readFileSync(authPath, "utf8")) as { + const persisted = commonSession(home, "fx_login") as { client_id: string; access_token: string; }; @@ -2207,9 +2234,10 @@ test( expect(login.stdout).not.toContain("Code:"); expect(login.stderr).toBe(""); - const authPath = join(home, ".fx", "chatgpt-auth.json"); + const authPath = join(home, ".fx", "auth.json"); expect(existsSync(authPath)).toBe(true); expect(statSync(authPath).mode & 0o077).toBe(0); + expect(commonSession(home, "chatgpt_subscription")).toBeDefined(); const settingsPath = join(home, ".fx", "settings.json"); const selected = JSON.parse(readFileSync(settingsPath, "utf8")); expect(selected.provider).toBe("codex"); @@ -2274,7 +2302,7 @@ test( const logout = await runFx(["logout", "codex"], { env, timeoutMs: TIMEOUT }); expect(logout.code).toBe(0); expect(logout.stdout).toContain("Signed out of Codex."); - expect(existsSync(authPath)).toBe(false); + expect(commonSession(home, "chatgpt_subscription")).toBeUndefined(); }, 60_000, ); @@ -2304,9 +2332,10 @@ test( expect(login.stdout).toContain("Signed in with Grok."); expect(login.stderr).toBe(""); - const authPath = join(home, ".fx", "grok-auth.json"); + const authPath = join(home, ".fx", "auth.json"); expect(existsSync(authPath)).toBe(true); expect(statSync(authPath).mode & 0o077).toBe(0); + expect(commonSession(home, "grok_subscription")).toBeDefined(); const settings = JSON.parse(readFileSync(join(home, ".fx", "settings.json"), "utf8")); expect(settings.provider).toBe("grok"); expect(settings.models.grok).toBe("grok-4.20"); @@ -2365,7 +2394,7 @@ test( expect(logout.code, `stdout: ${logout.stdout}\nstderr: ${logout.stderr}`).toBe(0); expect(logout.stdout).toContain("Signed out of Grok."); expect(grok.requests.some((request) => request.path === "/oauth2/revoke")).toBe(true); - expect(existsSync(authPath)).toBe(false); + expect(commonSession(home, "grok_subscription")).toBeUndefined(); } finally { grok.stop(); } @@ -2398,7 +2427,7 @@ test( expect(result.stdout).not.toContain("grok-code"); expect(result.stderr).toBe(""); expect(grok.tokenCalls()).toBe(1); - expect(existsSync(join(home, ".fx", "grok-auth.json"))).toBe(true); + expect(commonSession(home, "grok_subscription")).toBeDefined(); } finally { grok.stop(); } @@ -2416,7 +2445,6 @@ test("Grok logout removes local credentials when remote revocation fails", async JSON.stringify({ provider: "grok", grok_model: "grok-4.20" }) + "\n", { mode: 0o600 }, ); - const authPath = join(home, ".fx", "grok-auth.json"); const result = await runFx(["logout", "grok"], { env: { HOME: home, @@ -2429,7 +2457,7 @@ test("Grok logout removes local credentials when remote revocation fails", async expect(result.code, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(0); expect(result.stdout).toContain("Signed out of Grok."); expect(result.stderr).toContain("remote revocation could not be confirmed"); - expect(existsSync(authPath)).toBe(false); + expect(commonSession(home, "grok_subscription")).toBeUndefined(); expect(JSON.parse(readFileSync(join(home, ".fx", "settings.json"), "utf8")).provider) .toBe("grok"); const ask = await runFx(["ask", "--json", "--no-save", "Still Grok?"], { @@ -2470,7 +2498,7 @@ test("Grok 401 replay refuses a different account before the second provider sen }); expect(ask.code).toBe(1); expect(grok.requests.filter((request) => request.path === "/v1/responses")).toHaveLength(1); - const saved = JSON.parse(readFileSync(join(home, ".fx", "grok-auth.json"), "utf8")) as { + const saved = persistedSession(home, "grok_subscription", "grok-auth.json") as { access_token: string; account_id: string; }; @@ -2665,7 +2693,7 @@ tmuxTest( const scrollback = await session.captureFullScrollback(); expect(scrollback).not.toContain("grok-code"); expect(grok.tokenCalls()).toBe(1); - expect(existsSync(join(home, ".fx", "grok-auth.json"))).toBe(true); + expect(commonSession(home, "grok_subscription")).toBeDefined(); expect(readFileSync(stderrPath, "utf8")).toBe(""); } finally { grok.stop(); @@ -2915,7 +2943,7 @@ test( expect(login.code).toBe(1); expect(login.stdout).not.toContain("Signed in with Codex."); expect(login.stderr).toContain("fx login: could not load the target model catalog (malformed_response)"); - expect(existsSync(join(home, ".fx", "chatgpt-auth.json"))).toBe(true); + expect(commonSession(home, "chatgpt_subscription")).toBeDefined(); const settingsPath = join(home, ".fx", "settings.json"); expect(existsSync(settingsPath)).toBe(false); }, @@ -2947,7 +2975,7 @@ test( expect(login.code).toBe(1); expect(login.stdout).not.toContain("Signed in with Grok."); expect(login.stderr).toContain("fx login: target model catalog is empty"); - expect(existsSync(join(home, ".fx", "grok-auth.json"))).toBe(true); + expect(commonSession(home, "grok_subscription")).toBeDefined(); expect(existsSync(join(home, ".fx", "settings.json"))).toBe(false); } finally { grok.stop(); @@ -3065,7 +3093,7 @@ test( ); test( - "saved provider switching publishes Gateway, Codex, and Grok usage to one profile ledger", + "saved provider switching uses one common auth document and one profile ledger", async () => { home = mkdtempSync(join(tmpdir(), "fx-provider-usage-ledger-")); const workspace = join(home, "workspace"); @@ -3118,11 +3146,12 @@ test( 5, ); try { + writeSeededFxLogin(home); writeSeededChatGptLogin(home, chatgptAccessToken("acct_usage")); writeSeededGrokLogin(home, "grok-usage-token", "acct_usage"); const env = { HOME: home, - AI_GATEWAY_API_KEY: "gateway-usage-key", + AI_GATEWAY_API_KEY: undefined, VERCEL_OIDC_TOKEN: undefined, FX_DISABLE_KEYCHAIN: "1", FX_AUTO_UPGRADE: "0", @@ -3177,6 +3206,13 @@ test( expect(gateway.requests).toHaveLength(1); expect(codex.responses).toBe(1); expect(grok.responses).toBe(1); + const auth = readCommonAuth(home); + expect(auth.version).toBe(2); + expect(auth.credentials.fx_login?.session).toBeDefined(); + expect(auth.credentials.chatgpt_subscription?.session).toBeDefined(); + expect(auth.credentials.grok_subscription?.session).toBeDefined(); + expect(existsSync(join(home, ".fx", "chatgpt-auth.json"))).toBe(false); + expect(existsSync(join(home, ".fx", "grok-auth.json"))).toBe(false); } finally { codex.stop(); grok.stop(); @@ -3495,7 +3531,7 @@ tmuxTest( expect( loggedOut.match(/remote session could not be revoked/g) ?? [], ).toHaveLength(1); - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); + expect(commonSession(home, "fx_login")).toBeUndefined(); await session.sendText("/status"); await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT); @@ -3552,7 +3588,7 @@ tmuxTest( await session.waitForComposer(TIMEOUT); await session.sendText("/logout"); await session.waitForText("Signed out of fx.", TIMEOUT); - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); + expect(commonSession(home, "fx_login")).toBeUndefined(); await session.sendText("/status"); await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT); @@ -3566,7 +3602,7 @@ tmuxTest( ); tmuxTest( - "logout removes an fx login rejected for unsafe permissions", + "logout fails closed for a common auth document with unsafe permissions", async () => { home = mkdtempSync(join(tmpdir(), "fx-tui-logout-rejected-login-")); stderrPath = join(home, "stderr.log"); @@ -3580,8 +3616,12 @@ tmuxTest( session = await startFx(home, stderrPath, gateway, oauth.issuerUrl); await session.waitForComposer(TIMEOUT); await session.sendText("/logout"); - const loggedOut = await session.waitForText("Signed out of fx.", TIMEOUT); - expect(existsSync(authPath)).toBe(false); + const failed = await session.waitForText( + "Could not confirm durable fx logout. The active source was recalculated.", + TIMEOUT, + ); + expect(failed).not.toContain("Signed out of fx."); + expect(existsSync(authPath)).toBe(true); await session.sendText("/status"); await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT); @@ -3592,7 +3632,7 @@ tmuxTest( "seeded-refresh-token", oauth.providerDetail, ]) { - expect(loggedOut).not.toContain(secret); + expect(failed).not.toContain(secret); } }, 60_000,