summaryrefslogtreecommitdiff
path: root/libpanto/src/auth.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-07 11:29:23 -0600
committert <t@tjp.lol>2026-07-07 12:15:28 -0600
commit621d7fee0ace729f8d44126032d2c6e13f72ee7f (patch)
treea2938b0a9e5c0930e8644721abbe94875df9ff08 /libpanto/src/auth.zig
parent0fa6d4209ff9b4a95e7d1955887aa4c73ee3423c (diff)
Move libpanto projects to libpantograph dependencyHEADmain
Remove the in-repo libpanto sources and binding projects from pantograph. Consume libpantograph through the Zig package URL at code.tjp.lol/libpantograph.git, including the Lua module artifact used by CLI extensions.
Diffstat (limited to 'libpanto/src/auth.zig')
-rw-r--r--libpanto/src/auth.zig954
1 files changed, 0 insertions, 954 deletions
diff --git a/libpanto/src/auth.zig b/libpanto/src/auth.zig
deleted file mode 100644
index f70b031..0000000
--- a/libpanto/src/auth.zig
+++ /dev/null
@@ -1,954 +0,0 @@
-//! Provider authentication: named auth sessions resolved into request-ready
-//! credentials before a provider stream opens.
-//!
-//! A provider names the auth session it uses (`auth = "<name>"`); core
-//! resolves that session into a `ResolvedCredential` (a bearer/api-key value,
-//! an optional dynamic `base_url`, and optional auth-derived request headers).
-//! This covers three families with one configuration shape:
-//!
-//! - `api_key` — static key from a literal or an environment variable.
-//! - `oauth_device` — OAuth 2.0 device-authorization flow. Two dialects:
-//! * `token` — standard device flow (GitHub Copilot). The poll endpoint
-//! returns the OAuth token response directly.
-//! * `codex` — OpenAI Codex device flow. The poll endpoint returns an
-//! authorization code plus a server-generated PKCE verifier; core
-//! exchanges those at `token_url` for `{access,refresh,id}_token`.
-//!
-//! This module owns the *mechanism* (config types, the persisted token shape,
-//! and — added incrementally — the HTTP flows and refresh lifecycle). It is
-//! UI- and filesystem-policy-agnostic: token storage takes a directory path,
-//! and interactive device-code prompts are delivered through a caller-supplied
-//! `Presenter`. That keeps libpanto reusable while the embedder owns token
-//! storage policy and how a device code is shown to the user.
-
-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.
-pub const Header = config_mod.Header;
-
-// ===========================================================================
-// Auth configuration (parsed from `[auth.<name>]`)
-// ===========================================================================
-
-pub const AuthType = enum { api_key, oauth_device };
-
-/// Device-flow completion shape. See the module doc.
-pub const DeviceDialect = enum { token, codex };
-
-/// Wire encoding for the `token` dialect's device-code / poll request bodies.
-pub const TokenRequestFormat = enum { form, json };
-
-/// Static API-key auth. `key` is the resolved credential — a literal, or the
-/// result of `${env:VAR}`-style substitution performed by the embedder. An
-/// absent/empty key means unresolved; providers using such a session are
-/// omitted from the active config (export the key and they reappear).
-pub const ApiKeyAuth = struct {
- key: ?[]const u8 = null,
-};
-
-/// A secondary token exchange run after the durable OAuth token is obtained
-/// (GitHub Copilot's `/copilot_internal/v2/token`). The exchange result —
-/// not the OAuth token — becomes the request credential, and may also
-/// override the provider `base_url`.
-pub const ExchangeConfig = struct {
- method: []const u8 = "GET",
- url: []const u8,
- /// Which stored token to send as the exchange request's bearer.
- bearer: BearerSource = .oauth_access_token,
- /// Dotted JSON path to the credential in the exchange response.
- token_json_path: []const u8 = "token",
- /// Dotted JSON path to the credential's unix-seconds expiry, if any.
- expires_at_json_path: ?[]const u8 = null,
- /// Dotted JSON path to a dynamic `base_url` in the exchange response.
- base_url_json_path: ?[]const u8 = null,
-
- pub const BearerSource = enum { oauth_access_token };
-};
-
-/// OAuth 2.0 device-authorization auth.
-pub const OAuthDeviceAuth = struct {
- dialect: DeviceDialect = .token,
- client_id: []const u8,
- /// Endpoint that issues the device/user code.
- device_code_url: []const u8,
- /// `token` dialect: poll-for-token and refresh endpoint.
- /// `codex` dialect: authorization-code exchange and refresh endpoint.
- token_url: []const u8,
- /// `codex` dialect: endpoint polled for the authorization code.
- device_poll_url: ?[]const u8 = null,
- /// Browser URL shown to the user (codex). For the `token` dialect the
- /// verification URI comes from the device-code response.
- verification_url: ?[]const u8 = null,
- scope: ?[]const u8 = null,
- /// Request encoding for the `token` dialect device-code / poll bodies.
- token_request_format: TokenRequestFormat = .form,
- /// `codex` dialect: redirect URI sent in the code exchange.
- redirect_uri: ?[]const u8 = null,
- /// `codex` dialect: JWT claim (on the id_token) holding the account id,
- /// e.g. `https://api.openai.com/auth`. When set, the resolver extracts an
- /// account id and emits it as a header (see provider wiring).
- account_id_jwt_claim: ?[]const u8 = null,
- /// Optional post-login token exchange (Copilot).
- exchange: ?ExchangeConfig = null,
-};
-
-/// One named auth session's configuration. The union tag mirrors the TOML
-/// `type` field.
-pub const AuthConfig = union(AuthType) {
- api_key: ApiKeyAuth,
- oauth_device: OAuthDeviceAuth,
-};
-
-// ===========================================================================
-// Persisted token state (`<auth_dir>/<name>.json`)
-// ===========================================================================
-
-/// Durable auth state for one session. Only the fields relevant to the auth
-/// type are populated. Treat the on-disk file like a password.
-pub const TokenSet = struct {
- /// `"api_key"` | `"oauth_device"` — records which family wrote the file.
- type: []const u8 = "oauth_device",
- /// Durable OAuth access token (the `ghu_...` for Copilot; the JWT access
- /// token for Codex).
- access_token: ?[]const u8 = null,
- refresh_token: ?[]const u8 = null,
- id_token: ?[]const u8 = null,
- /// Unix-seconds expiry of `access_token` (when known; OAuth `expires_in`
- /// or a JWT `exp`). Null for tokens with no intrinsic expiry (Copilot's
- /// durable `ghu_` token).
- expires_at: ?i64 = null,
- /// Account id derived from the id_token (Codex `chatgpt-account-id`).
- account_id: ?[]const u8 = null,
- /// Result of the secondary exchange (Copilot short-lived API token).
- exchange: ?ExchangeToken = null,
-
- pub const ExchangeToken = struct {
- token: []const u8,
- /// Unix-seconds expiry of the exchanged token.
- expires_at: ?i64 = null,
- /// Dynamic base_url returned by the exchange, if any.
- base_url: ?[]const u8 = null,
- };
-};
-
-// ===========================================================================
-// Resolved credential (handed to the provider for one turn)
-// ===========================================================================
-
-/// The request-ready output of resolving an auth session: the secret to place
-/// in the provider's auth header, plus any dynamic base_url and auth-derived
-/// headers. The embedder injects these into the active `ProviderConfig`.
-pub const ResolvedCredential = struct {
- /// Value for the provider auth header (the bearer / x-api-key value).
- api_key: []const u8,
- /// Overrides the provider's configured `base_url` when present (e.g.
- /// Copilot's `endpoints.api`).
- base_url_override: ?[]const u8 = null,
- /// Auth-derived headers (e.g. `chatgpt-account-id`) to merge onto the
- /// provider's request.
- extra_headers: []const Header = &.{},
-};
-
-// ===========================================================================
-// Token storage (`<auth_dir>/<name>.json`)
-// ===========================================================================
-
-/// A parsed `TokenSet` plus the arena owning its strings. Call `deinit`.
-pub const ParsedTokenSet = std.json.Parsed(TokenSet);
-
-/// Owner-only file permissions for token files (POSIX 0o600). On Windows the
-/// platform `Permissions` enum has no mode, so fall back to its default.
-fn tokenFilePermissions() Io.File.Permissions {
- if (builtin.os.tag == .windows) return .default_file;
- return Io.File.Permissions.fromMode(0o600);
-}
-
-/// Load and parse `<auth_dir>/<name>.json`. Returns null if the file does not
-/// exist. The caller owns the result and must `deinit` it.
-pub fn loadTokenSet(
- allocator: std.mem.Allocator,
- io: Io,
- auth_dir: []const u8,
- name: []const u8,
-) !?ParsedTokenSet {
- const fname = try std.fmt.allocPrint(allocator, "{s}.json", .{name});
- defer allocator.free(fname);
-
- var dir = Io.Dir.cwd().openDir(io, auth_dir, .{}) catch |err| switch (err) {
- error.FileNotFound => return null,
- else => return err,
- };
- defer dir.close(io);
-
- const bytes = dir.readFileAlloc(io, fname, allocator, .limited(1 << 20)) catch |err| switch (err) {
- error.FileNotFound => return null,
- else => return err,
- };
- defer allocator.free(bytes);
-
- return try std.json.parseFromSlice(TokenSet, allocator, bytes, .{
- .ignore_unknown_fields = true,
- .allocate = .alloc_always,
- });
-}
-
-/// Serialize and write `ts` to `<auth_dir>/<name>.json` with owner-only
-/// permissions, creating `auth_dir` if needed. Null optional fields are
-/// omitted to keep the file tidy.
-pub fn saveTokenSet(
- allocator: std.mem.Allocator,
- io: Io,
- auth_dir: []const u8,
- name: []const u8,
- ts: TokenSet,
-) !void {
- Io.Dir.cwd().createDirPath(io, auth_dir) catch |err| switch (err) {
- error.PathAlreadyExists => {},
- else => return err,
- };
-
- const json = try std.json.Stringify.valueAlloc(allocator, ts, .{
- .emit_null_optional_fields = false,
- .whitespace = .indent_2,
- });
- defer allocator.free(json);
-
- const fname = try std.fmt.allocPrint(allocator, "{s}.json", .{name});
- defer allocator.free(fname);
-
- var dir = try Io.Dir.cwd().openDir(io, auth_dir, .{});
- defer dir.close(io);
- try dir.writeFile(io, .{
- .sub_path = fname,
- .data = json,
- .flags = .{ .permissions = tokenFilePermissions() },
- });
-}
-
-/// Delete `<auth_dir>/<name>.json`. Returns true if a file was removed, false
-/// if it did not exist (idempotent logout).
-pub fn deleteTokenSet(
- allocator: std.mem.Allocator,
- io: Io,
- auth_dir: []const u8,
- name: []const u8,
-) !bool {
- const fname = try std.fmt.allocPrint(allocator, "{s}.json", .{name});
- defer allocator.free(fname);
- var dir = Io.Dir.cwd().openDir(io, auth_dir, .{}) catch |err| switch (err) {
- error.FileNotFound => return false,
- else => return err,
- };
- defer dir.close(io);
- dir.deleteFile(io, fname) catch |err| switch (err) {
- error.FileNotFound => return false,
- else => return err,
- };
- 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. `headers` are the client-identity headers
-/// (the provider's `extra_headers`) sent on every auth HTTP call. Allocates
-/// the result into `arena`.
-fn requestDeviceAuth(
- arena: std.mem.Allocator,
- client: *std.http.Client,
- oauth: OAuthDeviceAuth,
- headers: []const Header,
-) !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 = 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,
- headers: []const Header,
-) !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 = 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 = 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, headers);
- 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,
- headers: []const Header,
-) !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 = 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,
- headers: []const Header,
-) !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 = 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,
- headers: []const Header,
-) !OAuthTokens {
- const da = try requestDeviceAuth(arena, client, oauth, headers);
- 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 = try pollOnce(arena, client, oauth, da, headers);
- 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,
- headers: []const Header,
-) !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 (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" {
- const io = t.io;
- var tmp = t.tmpDir(.{});
- defer tmp.cleanup();
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const n = try tmp.dir.realPath(io, &path_buf);
- const auth_dir = try std.fmt.allocPrint(t.allocator, "{s}/auth", .{path_buf[0..n]});
- defer t.allocator.free(auth_dir);
-
- // Absent => null.
- try t.expect((try loadTokenSet(t.allocator, io, auth_dir, "ghost")) == null);
-
- const ts: TokenSet = .{
- .type = "oauth_device",
- .access_token = "ghu_abc",
- .refresh_token = "rt_xyz",
- .expires_at = 1700000000,
- .exchange = .{ .token = "tkn", .expires_at = 1700001800, .base_url = "https://api.x" },
- };
- try saveTokenSet(t.allocator, io, auth_dir, "github_copilot", ts);
-
- var loaded = (try loadTokenSet(t.allocator, io, auth_dir, "github_copilot")).?;
- defer loaded.deinit();
- try t.expectEqualStrings("ghu_abc", loaded.value.access_token.?);
- try t.expectEqualStrings("rt_xyz", loaded.value.refresh_token.?);
- try t.expectEqual(@as(?i64, 1700000000), loaded.value.expires_at);
- try t.expect(loaded.value.id_token == null);
- try t.expectEqualStrings("tkn", loaded.value.exchange.?.token);
- try t.expectEqualStrings("https://api.x", loaded.value.exchange.?.base_url.?);
-
- try t.expect(try deleteTokenSet(t.allocator, io, auth_dir, "github_copilot"));
- try t.expect(!try deleteTokenSet(t.allocator, io, auth_dir, "github_copilot"));
- try t.expect((try loadTokenSet(t.allocator, io, auth_dir, "github_copilot")) == null);
-}
-
-test "AuthConfig: api_key variant" {
- const a: AuthConfig = .{ .api_key = .{ .key = "sk-resolved" } };
- try t.expectEqual(AuthType.api_key, @as(AuthType, a));
- try t.expectEqualStrings("sk-resolved", a.api_key.key.?);
-}
-
-test "AuthConfig: oauth_device defaults" {
- const a: AuthConfig = .{ .oauth_device = .{
- .client_id = "Iv1.x",
- .device_code_url = "https://github.com/login/device/code",
- .token_url = "https://github.com/login/oauth/access_token",
- } };
- try t.expectEqual(DeviceDialect.token, a.oauth_device.dialect);
- try t.expectEqual(TokenRequestFormat.form, a.oauth_device.token_request_format);
- try t.expect(a.oauth_device.exchange == null);
-}
-
-test "TokenSet: defaults" {
- const ts: TokenSet = .{};
- try t.expectEqualStrings("oauth_device", ts.type);
- 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.?);
-}