diff options
| author | t <t@tjp.lol> | 2026-06-13 12:02:11 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-15 15:08:32 -0600 |
| commit | 1b5917e9eb309b4b78e8d2b88b66e7f7bc1834ef (patch) | |
| tree | dc159460457f8ae4b3e8d7572733d976aab67ca6 /libpanto/src/auth.zig | |
| parent | fe2bfe443dfcf3251ebe39002325f604634dfa1f (diff) | |
auth: OAuth device flows, refresh, and Copilot exchange (C5)
Implement the device-authorization flow for both dialects (GitHub `token`
direct-poll; Codex `codex` poll→authorization-code→PKCE exchange), refresh_token
grant, Copilot secondary token exchange, JWT exp parsing, Codex account-id
extraction, and pure credential-building/refresh predicates. Adds a Presenter
hook for rendering the device-code prompt. Exported from public.zig.
Diffstat (limited to 'libpanto/src/auth.zig')
| -rw-r--r-- | libpanto/src/auth.zig | 637 |
1 files changed, 637 insertions, 0 deletions
diff --git a/libpanto/src/auth.zig b/libpanto/src/auth.zig index 19e08fe..9bf37b4 100644 --- a/libpanto/src/auth.zig +++ b/libpanto/src/auth.zig @@ -25,6 +25,7 @@ const std = @import("std"); const builtin = @import("builtin"); const Io = std.Io; const config_mod = @import("config.zig"); +const http = @import("http_helper.zig"); /// A single request header. Re-exported from `config.zig` so callers can /// build auth-derived headers and provider `extra_headers` from one type. @@ -253,6 +254,495 @@ pub fn deleteTokenSet( return true; } +// =========================================================================== +// OAuth device flow +// =========================================================================== + +pub const AuthError = error{ + /// No persisted token and no interactive presenter to run a login. + AuthLoginRequired, + /// The OAuth endpoint returned an error (denied, expired, malformed). + AuthFlowFailed, + /// The device-flow poll exceeded its deadline. + AuthLoginTimeout, + /// The configured auth could not be resolved into a credential. + AuthUnresolved, +}; + +/// The device-code prompt shown to the user. The embedder renders this +/// (a verification URL + a short user code) however suits its UI. +pub const DeviceCodePrompt = struct { + verification_uri: []const u8, + user_code: []const u8, + /// Seconds until the code expires (0 if the endpoint didn't say). + expires_in: i64 = 0, +}; + +/// Caller-supplied UI hook for interactive device login. `onDeviceCode` is +/// called once with the prompt; `onStatus` (optional) receives progress text. +pub const Presenter = struct { + ptr: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + on_device_code: *const fn (ptr: *anyopaque, prompt: DeviceCodePrompt) void, + on_status: ?*const fn (ptr: *anyopaque, msg: []const u8) void = null, + }; + + fn deviceCode(self: Presenter, prompt: DeviceCodePrompt) void { + self.vtable.on_device_code(self.ptr, prompt); + } + fn status(self: Presenter, msg: []const u8) void { + if (self.vtable.on_status) |f| f(self.ptr, msg); + } +}; + +/// The OAuth token-endpoint response, fields borrowed from an arena. +pub const OAuthTokens = struct { + access_token: []const u8, + refresh_token: ?[]const u8 = null, + id_token: ?[]const u8 = null, + /// Seconds-to-live from `expires_in`, if present. + expires_in: ?i64 = null, +}; + +/// Identifiers needed to poll a started device authorization. +const DeviceAuth = struct { + /// `token` dialect poll parameter. + device_code: ?[]const u8 = null, + /// `codex` dialect poll parameter. + device_auth_id: ?[]const u8 = null, + user_code: []const u8, + verification_uri: []const u8, + interval_secs: u32 = 5, + expires_in: i64 = 0, +}; + +/// `application/x-www-form-urlencoded` body from name/value pairs. Values are +/// percent-encoded (unreserved chars pass through). Allocated in `arena`. +fn formEncode(arena: std.mem.Allocator, pairs: []const [2][]const u8) ![]u8 { + var out: std.ArrayList(u8) = .empty; + for (pairs, 0..) |kv, i| { + if (i != 0) try out.append(arena, '&'); + try percentEncode(arena, &out, kv[0]); + try out.append(arena, '='); + try percentEncode(arena, &out, kv[1]); + } + return out.toOwnedSlice(arena); +} + +fn percentEncode(arena: std.mem.Allocator, out: *std.ArrayList(u8), s: []const u8) !void { + for (s) |c| { + const unreserved = (c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z') or + (c >= '0' and c <= '9') or c == '-' or c == '.' or c == '_' or c == '~'; + if (unreserved) { + try out.append(arena, c); + } else { + try out.print(arena, "%{X:0>2}", .{c}); + } + } +} + +/// Build the device-code / token request body in the dialect's encoding. +fn encodeBody( + arena: std.mem.Allocator, + format: TokenRequestFormat, + pairs: []const [2][]const u8, +) !struct { body: []u8, content_type: []const u8 } { + switch (format) { + .form => return .{ .body = try formEncode(arena, pairs), .content_type = "application/x-www-form-urlencoded" }, + .json => { + var aw: std.Io.Writer.Allocating = .init(arena); + var s: std.json.Stringify = .{ .writer = &aw.writer }; + try s.beginObject(); + for (pairs) |kv| { + try s.objectField(kv[0]); + try s.write(kv[1]); + } + try s.endObject(); + return .{ .body = aw.written(), .content_type = "application/json" }; + }, + } +} + +/// Request a device/user code. Allocates the result into `arena`. +fn requestDeviceAuth( + arena: std.mem.Allocator, + client: *std.http.Client, + oauth: OAuthDeviceAuth, +) !DeviceAuth { + const enc = switch (oauth.dialect) { + // GitHub: `client_id` (+ optional scope), in the configured encoding. + .token => try encodeBody(arena, oauth.token_request_format, blk: { + if (oauth.scope) |sc| { + break :blk &[_][2][]const u8{ .{ "client_id", oauth.client_id }, .{ "scope", sc } }; + } else break :blk &[_][2][]const u8{.{ "client_id", oauth.client_id }}; + }), + // Codex: JSON `{client_id}`. + .codex => try encodeBody(arena, .json, &.{.{ "client_id", oauth.client_id }}), + }; + + const resp = try http.request(arena, client, .POST, oauth.device_code_url, .{ + .headers = oauth.headers, + .body = enc.body, + .content_type = enc.content_type, + }); + if (!resp.ok()) return AuthError.AuthFlowFailed; + + const parsed = std.json.parseFromSlice(std.json.Value, arena, resp.body, .{}) catch + return AuthError.AuthFlowFailed; + const v = parsed.value; + + const user_code = http.jsonStringAtPath(v, "user_code") orelse + http.jsonStringAtPath(v, "usercode") orelse return AuthError.AuthFlowFailed; + const interval: u32 = blk: { + if (http.jsonIntAtPath(v, "interval")) |iv| break :blk @intCast(@max(iv, 1)); + // Codex returns interval as a string. + if (http.jsonStringAtPath(v, "interval")) |is| { + if (std.fmt.parseInt(i64, is, 10) catch null) |iv| break :blk @intCast(@max(iv, 1)); + } + break :blk 5; + }; + const verification_uri = switch (oauth.dialect) { + .token => http.jsonStringAtPath(v, "verification_uri") orelse + http.jsonStringAtPath(v, "verification_uri_complete") orelse return AuthError.AuthFlowFailed, + .codex => oauth.verification_url orelse return AuthError.AuthFlowFailed, + }; + return .{ + .device_code = http.jsonStringAtPath(v, "device_code"), + .device_auth_id = http.jsonStringAtPath(v, "device_auth_id"), + .user_code = user_code, + .verification_uri = verification_uri, + .interval_secs = interval, + .expires_in = http.jsonIntAtPath(v, "expires_in") orelse 0, + }; +} + +const PollResult = union(enum) { + pending, + done: OAuthTokens, +}; + +/// Poll once for completion. Allocates any returned tokens into `arena`. +fn pollOnce( + arena: std.mem.Allocator, + client: *std.http.Client, + oauth: OAuthDeviceAuth, + da: DeviceAuth, +) !PollResult { + switch (oauth.dialect) { + .token => { + const enc = try encodeBody(arena, oauth.token_request_format, &.{ + .{ "client_id", oauth.client_id }, + .{ "device_code", da.device_code orelse return AuthError.AuthFlowFailed }, + .{ "grant_type", "urn:ietf:params:oauth:grant-type:device_code" }, + }); + const resp = try http.request(arena, client, .POST, oauth.token_url, .{ + .headers = oauth.headers, + .body = enc.body, + .content_type = enc.content_type, + }); + const parsed = std.json.parseFromSlice(std.json.Value, arena, resp.body, .{}) catch + return AuthError.AuthFlowFailed; + const v = parsed.value; + if (http.jsonStringAtPath(v, "error")) |e| { + if (std.mem.eql(u8, e, "authorization_pending") or std.mem.eql(u8, e, "slow_down")) + return .pending; + return AuthError.AuthFlowFailed; + } + const tokens = parseOAuthTokens(v) orelse return AuthError.AuthFlowFailed; + return .{ .done = tokens }; + }, + .codex => { + const poll_url = oauth.device_poll_url orelse return AuthError.AuthFlowFailed; + const enc = try encodeBody(arena, .json, &.{ + .{ "device_auth_id", da.device_auth_id orelse return AuthError.AuthFlowFailed }, + .{ "user_code", da.user_code }, + }); + const resp = try http.request(arena, client, .POST, poll_url, .{ + .headers = oauth.headers, + .body = enc.body, + .content_type = enc.content_type, + }); + // 403/404 => still pending (reference behavior). + if (resp.status == 403 or resp.status == 404) return .pending; + if (!resp.ok()) return AuthError.AuthFlowFailed; + const parsed = std.json.parseFromSlice(std.json.Value, arena, resp.body, .{}) catch + return AuthError.AuthFlowFailed; + const code = http.jsonStringAtPath(parsed.value, "authorization_code") orelse return .pending; + const verifier = http.jsonStringAtPath(parsed.value, "code_verifier") orelse return AuthError.AuthFlowFailed; + // Exchange the authorization code + server-generated PKCE verifier. + const tokens = try exchangeCode(arena, client, oauth, code, verifier); + return .{ .done = tokens }; + }, + } +} + +/// Codex authorization-code exchange at the token endpoint. +fn exchangeCode( + arena: std.mem.Allocator, + client: *std.http.Client, + oauth: OAuthDeviceAuth, + code: []const u8, + verifier: []const u8, +) !OAuthTokens { + const redirect = oauth.redirect_uri orelse "https://auth.openai.com/deviceauth/callback"; + const enc = try encodeBody(arena, .form, &.{ + .{ "grant_type", "authorization_code" }, + .{ "client_id", oauth.client_id }, + .{ "code", code }, + .{ "code_verifier", verifier }, + .{ "redirect_uri", redirect }, + }); + const resp = try http.request(arena, client, .POST, oauth.token_url, .{ + .headers = oauth.headers, + .body = enc.body, + .content_type = enc.content_type, + }); + if (!resp.ok()) return AuthError.AuthFlowFailed; + const parsed = std.json.parseFromSlice(std.json.Value, arena, resp.body, .{}) catch + return AuthError.AuthFlowFailed; + return parseOAuthTokens(parsed.value) orelse AuthError.AuthFlowFailed; +} + +/// Refresh an access token. Allocates into `arena`. +pub fn refreshTokens( + arena: std.mem.Allocator, + client: *std.http.Client, + oauth: OAuthDeviceAuth, + refresh_token: []const u8, +) !OAuthTokens { + const enc = try encodeBody(arena, .form, &.{ + .{ "grant_type", "refresh_token" }, + .{ "refresh_token", refresh_token }, + .{ "client_id", oauth.client_id }, + }); + const resp = try http.request(arena, client, .POST, oauth.token_url, .{ + .headers = oauth.headers, + .body = enc.body, + .content_type = enc.content_type, + }); + if (!resp.ok()) return AuthError.AuthFlowFailed; + const parsed = std.json.parseFromSlice(std.json.Value, arena, resp.body, .{}) catch + return AuthError.AuthFlowFailed; + return parseOAuthTokens(parsed.value) orelse AuthError.AuthFlowFailed; +} + +/// Pluck an `OAuthTokens` out of a parsed token response. Null if no +/// `access_token`. Borrows from the parsed value. +fn parseOAuthTokens(v: std.json.Value) ?OAuthTokens { + const access = http.jsonStringAtPath(v, "access_token") orelse return null; + return .{ + .access_token = access, + .refresh_token = http.jsonStringAtPath(v, "refresh_token"), + .id_token = http.jsonStringAtPath(v, "id_token"), + .expires_in = http.jsonIntAtPath(v, "expires_in"), + }; +} + +/// Run the full interactive device login: request a code, present it, then +/// poll until authorized (or the deadline passes). Allocates the resulting +/// tokens into `arena`. +pub fn login( + arena: std.mem.Allocator, + io: Io, + client: *std.http.Client, + oauth: OAuthDeviceAuth, + presenter: Presenter, +) !OAuthTokens { + const da = try requestDeviceAuth(arena, client, oauth); + presenter.deviceCode(.{ + .verification_uri = da.verification_uri, + .user_code = da.user_code, + .expires_in = da.expires_in, + }); + presenter.status("waiting for authorization…"); + + const start_ns = std.Io.Clock.now(.real, io).nanoseconds; + const deadline_ns = start_ns + 15 * std.time.ns_per_min; + while (true) { + io.sleep(.fromSeconds(@intCast(da.interval_secs)), .real) catch {}; + const result = pollOnce(arena, client, oauth, da) catch |err| switch (err) { + // A transient transport hiccup mid-poll: keep waiting. + error.AuthFlowFailed => return err, + else => return err, + }; + switch (result) { + .pending => {}, + .done => |toks| return toks, + } + if (std.Io.Clock.now(.real, io).nanoseconds >= deadline_ns) return AuthError.AuthLoginTimeout; + } +} + +// =========================================================================== +// Secondary token exchange (Copilot) +// =========================================================================== + +/// Run the configured exchange (Copilot's `/v2/token`) using `bearer` as the +/// request bearer. Returns the exchanged token + optional expiry/base_url, +/// allocated into `arena`. +pub fn runExchange( + arena: std.mem.Allocator, + client: *std.http.Client, + exchange: ExchangeConfig, + bearer: []const u8, +) !TokenSet.ExchangeToken { + const auth_value = try std.fmt.allocPrint(arena, "Bearer {s}", .{bearer}); + var hdrs: std.ArrayList(Header) = .empty; + try hdrs.append(arena, .{ .name = "authorization", .value = auth_value }); + for (exchange.headers) |h| try hdrs.append(arena, h); + + const method: http.Method = if (std.ascii.eqlIgnoreCase(exchange.method, "POST")) .POST else .GET; + const resp = try http.request(arena, client, method, exchange.url, .{ .headers = hdrs.items }); + if (!resp.ok()) return AuthError.AuthFlowFailed; + + const parsed = std.json.parseFromSlice(std.json.Value, arena, resp.body, .{}) catch + return AuthError.AuthFlowFailed; + const v = parsed.value; + const token = http.jsonStringAtPath(v, exchange.token_json_path) orelse return AuthError.AuthFlowFailed; + return .{ + .token = token, + .expires_at = if (exchange.expires_at_json_path) |p| http.jsonIntAtPath(v, p) else null, + .base_url = if (exchange.base_url_json_path) |p| http.jsonStringAtPath(v, p) else null, + }; +} + +// =========================================================================== +// JWT + credential building (pure) +// =========================================================================== + +/// Decode a JWT's payload (the middle segment) into a parsed JSON value. +/// Returns null if the token is malformed. Caller owns the result. +pub fn decodeJwtPayload(allocator: std.mem.Allocator, token: []const u8) ?std.json.Parsed(std.json.Value) { + var it = std.mem.splitScalar(u8, token, '.'); + _ = it.next() orelse return null; // header + const payload_b64 = it.next() orelse return null; + if (it.next() == null) return null; // require a signature segment + + const dec = std.base64.url_safe_no_pad.Decoder; + const n = dec.calcSizeForSlice(payload_b64) catch return null; + const buf = allocator.alloc(u8, n) catch return null; + defer allocator.free(buf); + dec.decode(buf, payload_b64) catch return null; + return std.json.parseFromSlice(std.json.Value, allocator, buf, .{}) catch null; +} + +/// The `exp` (unix-seconds expiry) claim of a JWT access/id token, or null. +pub fn parseJwtExp(allocator: std.mem.Allocator, token: []const u8) ?i64 { + var parsed = decodeJwtPayload(allocator, token) orelse return null; + defer parsed.deinit(); + return http.jsonIntAtPath(parsed.value, "exp"); +} + +/// Extract the ChatGPT account id from an id_token. `claim_path` is the JWT +/// claim holding the auth object (e.g. `https://api.openai.com/auth`); the +/// account id is its `chatgpt_account_id` field. Returns an owned copy. +pub fn extractAccountId( + allocator: std.mem.Allocator, + id_token: []const u8, + claim_path: []const u8, +) ?[]u8 { + var parsed = decodeJwtPayload(allocator, id_token) orelse return null; + defer parsed.deinit(); + // `claim_path` is a single literal claim key (e.g. + // `https://api.openai.com/auth`) that itself contains dots, so it is + // looked up directly rather than walked as a dotted path. + const claim = switch (parsed.value) { + .object => |o| o.get(claim_path) orelse return null, + else => return null, + }; + const account = switch (claim) { + .object => |o| o.get("chatgpt_account_id") orelse return null, + else => return null, + }; + return switch (account) { + .string => |s| allocator.dupe(u8, s) catch null, + else => null, + }; +} + +/// True when an access token is missing or within `margin` seconds of expiry. +pub fn needsRefresh(ts: TokenSet, now_unix: i64, margin: i64) bool { + if (ts.access_token == null) return false; // nothing to refresh (login path) + const exp = ts.expires_at orelse return false; // no intrinsic expiry (e.g. ghu_) + return exp - now_unix <= margin; +} + +/// True when an exchange is configured but the stored exchange token is +/// missing or within `margin` seconds of expiry. +pub fn needsExchange(oauth: OAuthDeviceAuth, ts: TokenSet, now_unix: i64, margin: i64) bool { + if (oauth.exchange == null) return false; + const ex = ts.exchange orelse return true; + const exp = ex.expires_at orelse return false; // no expiry => assume durable + return exp - now_unix <= margin; +} + +/// Build the request-ready credential from a (refreshed/exchanged) token set. +/// The exchanged token wins over the access token; a codex account id becomes +/// a `chatgpt-account-id` header. All strings are duped into `arena`. +pub fn buildCredential( + arena: std.mem.Allocator, + oauth: OAuthDeviceAuth, + ts: TokenSet, +) !ResolvedCredential { + var base_url_override: ?[]const u8 = null; + const secret: []const u8 = blk: { + if (ts.exchange) |ex| { + if (ex.base_url) |bu| base_url_override = try arena.dupe(u8, bu); + break :blk ex.token; + } + break :blk ts.access_token orelse return AuthError.AuthUnresolved; + }; + + var headers: std.ArrayList(Header) = .empty; + if (oauth.account_id_jwt_claim != null) { + if (ts.account_id) |aid| { + try headers.append(arena, .{ + .name = "chatgpt-account-id", + .value = try arena.dupe(u8, aid), + }); + } + } + + return .{ + .api_key = try arena.dupe(u8, secret), + .base_url_override = base_url_override, + .extra_headers = try headers.toOwnedSlice(arena), + }; +} + +/// Assemble a persisted `TokenSet` from a fresh OAuth token response. +/// `expires_at` is derived from `expires_in` (preferred) or the access +/// token's JWT `exp`. A codex account id is extracted when configured. All +/// strings are duped into `arena`. +pub fn tokensToTokenSet( + arena: std.mem.Allocator, + oauth: OAuthDeviceAuth, + tokens: OAuthTokens, + now_unix: i64, +) !TokenSet { + const access = try arena.dupe(u8, tokens.access_token); + const expires_at: ?i64 = blk: { + if (tokens.expires_in) |e| break :blk now_unix + e; + break :blk parseJwtExp(arena, access); + }; + var account_id: ?[]const u8 = null; + if (oauth.account_id_jwt_claim) |claim| { + if (tokens.id_token) |idt| { + account_id = extractAccountId(arena, idt, claim); + } + } + return .{ + .type = "oauth_device", + .access_token = access, + .refresh_token = if (tokens.refresh_token) |r| try arena.dupe(u8, r) else null, + .id_token = if (tokens.id_token) |i| try arena.dupe(u8, i) else null, + .expires_at = expires_at, + .account_id = account_id, + }; +} + const t = std.testing; test "token storage: save, load, delete round-trip" { @@ -313,3 +803,150 @@ test "TokenSet: defaults" { try t.expect(ts.access_token == null); try t.expect(ts.exchange == null); } + +/// Build a `header.payload.sig` JWT whose payload is `payload_json`, +/// base64url-no-pad encoded. Allocated in `arena`. +fn makeJwt(arena: std.mem.Allocator, payload_json: []const u8) ![]u8 { + const enc = std.base64.url_safe_no_pad.Encoder; + const header = "{\"alg\":\"none\"}"; + var hbuf: [64]u8 = undefined; + const h = enc.encode(&hbuf, header); + const pbuf = try arena.alloc(u8, enc.calcSize(payload_json.len)); + const p = enc.encode(pbuf, payload_json); + return std.fmt.allocPrint(arena, "{s}.{s}.sig", .{ h, p }); +} + +test "parseJwtExp + extractAccountId" { + var aa = std.heap.ArenaAllocator.init(t.allocator); + defer aa.deinit(); + const a = aa.allocator(); + const jwt = try makeJwt(a, + \\{"exp":1700000000,"https://api.openai.com/auth":{"chatgpt_account_id":"acct_123"}} + ); + try t.expectEqual(@as(?i64, 1700000000), parseJwtExp(a, jwt)); + const aid = extractAccountId(a, jwt, "https://api.openai.com/auth").?; + try t.expectEqualStrings("acct_123", aid); + // Wrong claim path => null. + try t.expect(extractAccountId(a, jwt, "nope") == null); + // Malformed token => null. + try t.expect(parseJwtExp(a, "not-a-jwt") == null); +} + +test "parseOAuthTokens: required access_token, optional rest" { + var parsed = try std.json.parseFromSlice(std.json.Value, t.allocator, + \\{"access_token":"at","refresh_token":"rt","id_token":"it","expires_in":1800} + , .{}); + defer parsed.deinit(); + const toks = parseOAuthTokens(parsed.value).?; + try t.expectEqualStrings("at", toks.access_token); + try t.expectEqualStrings("rt", toks.refresh_token.?); + try t.expectEqual(@as(?i64, 1800), toks.expires_in); + + var p2 = try std.json.parseFromSlice(std.json.Value, t.allocator, "{\"error\":\"x\"}", .{}); + defer p2.deinit(); + try t.expect(parseOAuthTokens(p2.value) == null); +} + +test "formEncode: percent-encodes reserved characters" { + var aa = std.heap.ArenaAllocator.init(t.allocator); + defer aa.deinit(); + const body = try formEncode(aa.allocator(), &.{ + .{ "grant_type", "urn:ietf:params:oauth:grant-type:device_code" }, + .{ "client_id", "Iv1.abc" }, + }); + try t.expectEqualStrings( + "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&client_id=Iv1.abc", + body, + ); +} + +test "needsRefresh / needsExchange predicates" { + const now: i64 = 1000; + // Durable token (no expiry) never needs refresh. + try t.expect(!needsRefresh(.{ .access_token = "x" }, now, 120)); + // Within margin => refresh. + try t.expect(needsRefresh(.{ .access_token = "x", .expires_at = 1100 }, now, 120)); + // Comfortably fresh => no. + try t.expect(!needsRefresh(.{ .access_token = "x", .expires_at = 5000 }, now, 120)); + + const oauth: OAuthDeviceAuth = .{ + .client_id = "c", + .device_code_url = "u", + .token_url = "u", + .exchange = .{ .url = "https://x" }, + }; + // Exchange configured but none stored => needs exchange. + try t.expect(needsExchange(oauth, .{ .access_token = "x" }, now, 120)); + // Stored exchange near expiry => needs exchange. + try t.expect(needsExchange(oauth, .{ .exchange = .{ .token = "t", .expires_at = 1050 } }, now, 120)); + // Fresh exchange => no. + try t.expect(!needsExchange(oauth, .{ .exchange = .{ .token = "t", .expires_at = 9000 } }, now, 120)); + // No exchange configured => never. + const no_ex: OAuthDeviceAuth = .{ .client_id = "c", .device_code_url = "u", .token_url = "u" }; + try t.expect(!needsExchange(no_ex, .{ .access_token = "x" }, now, 120)); +} + +test "buildCredential: copilot exchange token + dynamic base_url" { + var aa = std.heap.ArenaAllocator.init(t.allocator); + defer aa.deinit(); + const oauth: OAuthDeviceAuth = .{ + .client_id = "c", + .device_code_url = "u", + .token_url = "u", + .exchange = .{ .url = "https://x" }, + }; + const ts: TokenSet = .{ + .access_token = "ghu_durable", + .exchange = .{ .token = "tid", .base_url = "https://api.individual.githubcopilot.com" }, + }; + const cred = try buildCredential(aa.allocator(), oauth, ts); + try t.expectEqualStrings("tid", cred.api_key); + try t.expectEqualStrings("https://api.individual.githubcopilot.com", cred.base_url_override.?); + try t.expectEqual(@as(usize, 0), cred.extra_headers.len); +} + +test "buildCredential: codex access token + account-id header" { + var aa = std.heap.ArenaAllocator.init(t.allocator); + defer aa.deinit(); + const oauth: OAuthDeviceAuth = .{ + .dialect = .codex, + .client_id = "c", + .device_code_url = "u", + .token_url = "u", + .device_poll_url = "p", + .account_id_jwt_claim = "https://api.openai.com/auth", + }; + const ts: TokenSet = .{ .access_token = "jwt-access", .account_id = "acct_9" }; + const cred = try buildCredential(aa.allocator(), oauth, ts); + try t.expectEqualStrings("jwt-access", cred.api_key); + try t.expect(cred.base_url_override == null); + try t.expectEqual(@as(usize, 1), cred.extra_headers.len); + try t.expectEqualStrings("chatgpt-account-id", cred.extra_headers[0].name); + try t.expectEqualStrings("acct_9", cred.extra_headers[0].value); +} + +test "tokensToTokenSet: derives expires_at from expires_in and extracts account_id" { + var aa = std.heap.ArenaAllocator.init(t.allocator); + defer aa.deinit(); + const a = aa.allocator(); + const idt = try makeJwt(a, + \\{"https://api.openai.com/auth":{"chatgpt_account_id":"acct_77"}} + ); + const oauth: OAuthDeviceAuth = .{ + .dialect = .codex, + .client_id = "c", + .device_code_url = "u", + .token_url = "u", + .device_poll_url = "p", + .account_id_jwt_claim = "https://api.openai.com/auth", + }; + const ts = try tokensToTokenSet(a, oauth, .{ + .access_token = "at", + .refresh_token = "rt", + .id_token = idt, + .expires_in = 1800, + }, 1000); + try t.expectEqual(@as(?i64, 2800), ts.expires_at); + try t.expectEqualStrings("rt", ts.refresh_token.?); + try t.expectEqualStrings("acct_77", ts.account_id.?); +} |
