1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
|
//! 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 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.<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` (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/<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 (`$PANTO_HOME/auth/<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;
}
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);
}
|