//! Provider authentication: named auth sessions resolved into request-ready //! credentials before a provider stream opens. //! //! A provider names the auth session it uses (`auth = ""`); 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 where //! `$PANTO_HOME` is 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"); /// 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.]`) // =========================================================================== 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` (a literal) wins over `key_env_var`. pub const ApiKeyAuth = struct { key: ?[]const u8 = null, key_env_var: ?[]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, /// Headers sent with the exchange request (e.g. Copilot editor identity). headers: []const Header = &.{}, 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, /// Headers sent with the device-code / poll / token requests. headers: []const Header = &.{}, /// 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 (`$PANTO_HOME/auth/.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 (`$PANTO_HOME/auth/.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 `/.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 `/.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 `/.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; } 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_env_var = "OPENAI_API_KEY" } }; try t.expectEqual(AuthType.api_key, @as(AuthType, a)); try t.expectEqualStrings("OPENAI_API_KEY", a.api_key.key_env_var.?); } 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); }