diff --git a/docs/api.md b/docs/api.md index cdc31c8a..9a740ac4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -60,6 +60,8 @@ The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` an - Account-name refresh uses the account API by default. - A usable ChatGPT auth context with both `access_token` and `chatgpt_account_id` is required. If either value is missing, refresh is skipped before any request is sent. +- `chatgpt_account_id` is the stored ChatGPT account context. It normally comes from `tokens.account_id` or JWT `chatgpt_account_id`; for phone-login auth files that omit both legacy fields, it can be an `org-...` organization id selected from JWT `organizations[]`. +- Organization fallback prefers `is_default = true`; if no default organization is present, it uses the first non-empty organization id. - `login` refreshes immediately after the new active auth is ready. - Single-file `import` refreshes immediately for the imported auth context. - `list` and interactive `switch` refresh account names by default; `--api` is accepted as an explicit equivalent. @@ -83,7 +85,7 @@ That scope includes: - all records with the same `chatgpt_user_id` -`chatgpt_user_id` is the user identity for this flow. A single user may have multiple workspace `chatgpt_account_id` values, and those workspaces can include personal and Team records under the same email. +`chatgpt_user_id` is the user identity for this flow. A single user may have multiple workspace `chatgpt_account_id` values, and those values can be legacy account ids or organization fallback ids. This means a `free`, `plus`, or `pro` record can still trigger a grouped Team-name refresh when it belongs to the same `chatgpt_user_id` as Team records. @@ -131,4 +133,3 @@ Then: - `Team #1` is filled with `Prod Workspace` - `Team #2` is overwritten from `Old Workspace` to `Sandbox Workspace` - diff --git a/docs/implement.md b/docs/implement.md index a2008234..9180aa02 100644 --- a/docs/implement.md +++ b/docs/implement.md @@ -46,9 +46,15 @@ See [docs/schema-migration.md](./schema-migration.md) for versioning policy and For ChatGPT OAuth auth: -- `tokens.account_id` is stored as `chatgpt_account_id` and is used for API calls. +- `chatgpt_account_id` stores the ChatGPT account context used by local matching and ChatGPT API headers. +- Account context selection is ordered: + 1. use non-empty `tokens.account_id` + 2. otherwise use non-empty JWT `https://api.openai.com/auth.chatgpt_account_id` + 3. otherwise use JWT `https://api.openai.com/auth.organizations[].id` +- `organizations[].id` is only a fallback for auth files that omit the legacy account id fields. When it is used, `chatgpt_account_id` is an `org-...` workspace identifier rather than the legacy UUID account id. +- Organization fallback chooses the organization with `is_default = true`; if none exists, it uses the first organization with a non-empty `id`. - `chatgpt_user_id` is read from JWT auth claims, falling back to `user_id`. -- The local unique key is `record_key = chatgpt_user_id + "::" + chatgpt_account_id`. +- The local unique key is `record_key = chatgpt_user_id + "::" + chatgpt_account_id`; the second segment may be either a legacy account id or the organization fallback id. - `account_key` stores this local `record_key`. - Snapshot filenames are derived from `record_key`; filename-unsafe values are base64url-encoded. - Email is normalized to lowercase and used for display/grouping, not identity. @@ -67,12 +73,11 @@ For OpenAI API-key auth: If `OPENAI_API_KEY` is present, the account is treated as API-key auth. Otherwise, ChatGPT auth requires: - `tokens.access_token` -- `tokens.account_id` - `tokens.id_token` -- JWT `https://api.openai.com/auth.chatgpt_account_id` +- a ChatGPT account context from `tokens.account_id`, JWT `https://api.openai.com/auth.chatgpt_account_id`, or JWT `https://api.openai.com/auth.organizations[].id` - JWT user identity from `chatgpt_user_id` or `user_id` -If account identity fields are missing or mismatched, import/login fails. Existing-registry foreground sync skips unsyncable auth files and continues with registry state already on disk. +If required identity fields are missing or mismatched, import/login fails. Existing-registry foreground sync skips unsyncable auth files and continues with registry state already on disk. ## Active Auth Sync diff --git a/src/auth/auth.zig b/src/auth/auth.zig index ef1ea8ec..38cd22aa 100644 --- a/src/auth/auth.zig +++ b/src/auth/auth.zig @@ -157,6 +157,9 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn else => {}, } } + if (jwt_chatgpt_account_id == null) { + jwt_chatgpt_account_id = try organizationAccountIdAlloc(allocator, aobj); + } if (aobj.get("chatgpt_plan_type")) |pt| { switch (pt) { .string => |s| plan = parsePlanType(s), @@ -187,11 +190,7 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn } } - const chatgpt_account_id = token_chatgpt_account_id orelse return error.MissingAccountId; - if (jwt_chatgpt_account_id == null) return error.MissingAccountId; - if (!std.mem.eql(u8, chatgpt_account_id, jwt_chatgpt_account_id.?)) return error.AccountIdMismatch; - allocator.free(jwt_chatgpt_account_id.?); - jwt_chatgpt_account_id = null; + const chatgpt_account_id = try resolveChatGptAccountId(token_chatgpt_account_id, jwt_chatgpt_account_id); const chatgpt_user_id_value = chatgpt_user_id orelse return error.MissingChatgptUserId; const record_key = try recordKeyAlloc(allocator, chatgpt_user_id_value, chatgpt_account_id); @@ -207,7 +206,11 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn .auth_mode = .chatgpt, }; email = null; - token_chatgpt_account_id = null; + if (token_chatgpt_account_id != null) { + token_chatgpt_account_id = null; + } else { + jwt_chatgpt_account_id = null; + } chatgpt_user_id = null; access_token = null; last_refresh = null; @@ -331,6 +334,49 @@ fn parsePlanType(s: []const u8) registry.PlanType { return .unknown; } +fn organizationAccountIdAlloc(allocator: std.mem.Allocator, auth_obj: std.json.ObjectMap) !?[]u8 { + const organizations_val = auth_obj.get("organizations") orelse return null; + const organizations = switch (organizations_val) { + .array => |arr| arr, + else => return null, + }; + + var first_id: ?[]const u8 = null; + for (organizations.items) |organization_val| { + const organization_obj = switch (organization_val) { + .object => |obj| obj, + else => continue, + }; + const id = jsonStringField(organization_obj, "id") orelse continue; + if (id.len == 0) continue; + if (first_id == null) first_id = id; + + const is_default = if (organization_obj.get("is_default")) |is_default_val| switch (is_default_val) { + .bool => |value| value, + else => false, + } else false; + if (is_default) return try allocator.dupe(u8, id); + } + + if (first_id) |id| return try allocator.dupe(u8, id); + return null; +} + +fn resolveChatGptAccountId( + token_chatgpt_account_id: ?[]u8, + jwt_chatgpt_account_id: ?[]u8, +) ![]u8 { + if (token_chatgpt_account_id) |token_id| { + if (jwt_chatgpt_account_id) |jwt_id| { + if (!std.mem.eql(u8, token_id, jwt_id)) return error.AccountIdMismatch; + } + return token_id; + } + + const jwt_id = jwt_chatgpt_account_id orelse return error.MissingAccountId; + return jwt_id; +} + fn jsonStringField(obj: std.json.ObjectMap, key: []const u8) ?[]const u8 { const value = obj.get(key) orelse return null; return switch (value) { diff --git a/tests/auth_test.zig b/tests/auth_test.zig index bda7c817..a65a88af 100644 --- a/tests/auth_test.zig +++ b/tests/auth_test.zig @@ -56,6 +56,43 @@ test "parse auth info from jwt" { try std.testing.expect(std.mem.eql(u8, info.access_token.?, "access-user@example.com")); } +test "parse auth info uses default organization when account id is missing" { + const gpa = std.testing.allocator; + const chatgpt_account_id = "org-AAUtH9infujszmwhH1BkVd9n"; + const chatgpt_user_id = "user-FWx8fOqtJ2EIvndopK8mPrk4"; + + const header = "{\"alg\":\"none\",\"typ\":\"JWT\"}"; + const payload = "{\"email\":\"org-user@example.com\",\"https://api.openai.com/auth\":{\"organizations\":[{\"id\":\"org-other\",\"is_default\":false,\"role\":\"member\",\"title\":\"Other\"},{\"id\":\"org-AAUtH9infujszmwhH1BkVd9n\",\"is_default\":true,\"role\":\"owner\",\"title\":\"Default\"}],\"groups\":[],\"localhost\":true,\"user_id\":\"user-FWx8fOqtJ2EIvndopK8mPrk4\"}}"; + + const h64 = try b64url(gpa, header); + defer gpa.free(h64); + const p64 = try b64url(gpa, payload); + defer gpa.free(p64); + + const jwt = try std.mem.concat(gpa, u8, &[_][]const u8{ h64, ".", p64, ".sig" }); + defer gpa.free(jwt); + + const json = try std.fmt.allocPrint( + gpa, + "{{\"tokens\":{{\"access_token\":\"access-org-user@example.com\",\"account_id\":\"\",\"id_token\":\"{s}\"}}}}", + .{jwt}, + ); + defer gpa.free(json); + + const info = try auth.parseAuthInfoData(gpa, json); + defer info.deinit(gpa); + try std.testing.expect(info.email != null); + try std.testing.expect(std.mem.eql(u8, info.email.?, "org-user@example.com")); + try std.testing.expect(info.chatgpt_account_id != null); + try std.testing.expect(std.mem.eql(u8, info.chatgpt_account_id.?, chatgpt_account_id)); + try std.testing.expect(info.chatgpt_user_id != null); + try std.testing.expect(std.mem.eql(u8, info.chatgpt_user_id.?, chatgpt_user_id)); + try std.testing.expect(info.record_key != null); + const expected_record_key = try std.fmt.allocPrint(gpa, "{s}::{s}", .{ chatgpt_user_id, chatgpt_account_id }); + defer gpa.free(expected_record_key); + try std.testing.expect(std.mem.eql(u8, info.record_key.?, expected_record_key)); +} + test "api key auth" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); diff --git a/tests/support/fixtures.zig b/tests/support/fixtures.zig index 50b287d6..4fa15c6a 100644 --- a/tests/support/fixtures.zig +++ b/tests/support/fixtures.zig @@ -163,12 +163,10 @@ pub fn authJsonWithoutEmailForEmail(allocator: std.mem.Allocator, email: []const pub fn authJsonWithoutAccountId(allocator: std.mem.Allocator, email: []const u8, plan: []const u8) ![]u8 { const chatgpt_user_id = try chatgptUserIdForEmailAlloc(allocator, email); defer allocator.free(chatgpt_user_id); - const chatgpt_account_id = try chatgptAccountIdForEmailAlloc(allocator, email); - defer allocator.free(chatgpt_account_id); const payload = try std.fmt.allocPrint( allocator, - "{{\"email\":\"{s}\",\"https://api.openai.com/auth\":{{\"chatgpt_account_id\":\"{s}\",\"chatgpt_user_id\":\"{s}\",\"user_id\":\"{s}\",\"chatgpt_plan_type\":\"{s}\"}}}}", - .{ email, chatgpt_account_id, chatgpt_user_id, chatgpt_user_id, plan }, + "{{\"email\":\"{s}\",\"https://api.openai.com/auth\":{{\"chatgpt_user_id\":\"{s}\",\"user_id\":\"{s}\",\"chatgpt_plan_type\":\"{s}\"}}}}", + .{ email, chatgpt_user_id, chatgpt_user_id, plan }, ); defer allocator.free(payload); const auth = try authJsonFromPayload(allocator, payload);