Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down Expand Up @@ -131,4 +133,3 @@ Then:

- `Team #1` is filled with `Prod Workspace`
- `Team #2` is overwritten from `Old Workspace` to `Sandbox Workspace`

15 changes: 10 additions & 5 deletions docs/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
58 changes: 52 additions & 6 deletions src/auth/auth.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);

Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
37 changes: 37 additions & 0 deletions tests/auth_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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(.{});
Expand Down
6 changes: 2 additions & 4 deletions tests/support/fixtures.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading