summaryrefslogtreecommitdiff
path: root/libpanto/src
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src')
-rw-r--r--libpanto/src/auth.zig637
-rw-r--r--libpanto/src/public.zig16
2 files changed, 653 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.?);
+}
diff --git a/libpanto/src/public.zig b/libpanto/src/public.zig
index aa2f7e8..2c15f1e 100644
--- a/libpanto/src/public.zig
+++ b/libpanto/src/public.zig
@@ -93,6 +93,22 @@ pub const loadTokenSet = auth_mod.loadTokenSet;
pub const saveTokenSet = auth_mod.saveTokenSet;
pub const deleteTokenSet = auth_mod.deleteTokenSet;
+// OAuth device-flow mechanism: interactive login, refresh, exchange, and the
+// pure credential/JWT helpers the embedder's auth manager composes.
+pub const AuthError = auth_mod.AuthError;
+pub const Presenter = auth_mod.Presenter;
+pub const DeviceCodePrompt = auth_mod.DeviceCodePrompt;
+pub const OAuthTokens = auth_mod.OAuthTokens;
+pub const oauthLogin = auth_mod.login;
+pub const refreshTokens = auth_mod.refreshTokens;
+pub const runExchange = auth_mod.runExchange;
+pub const buildCredential = auth_mod.buildCredential;
+pub const tokensToTokenSet = auth_mod.tokensToTokenSet;
+pub const needsRefresh = auth_mod.needsRefresh;
+pub const needsExchange = auth_mod.needsExchange;
+pub const parseJwtExp = auth_mod.parseJwtExp;
+pub const extractAccountId = auth_mod.extractAccountId;
+
/// Non-streaming HTTP helper over the process-global client, plus JSON
/// path readers — the building blocks of the OAuth flows.
pub const http = @import("http_helper.zig");