summaryrefslogtreecommitdiff
path: root/libpanto
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-13 14:24:31 -0600
committert <t@tjp.lol>2026-06-15 15:08:32 -0600
commit20e6e08a42240aef0b36ecf627cbcde921912071 (patch)
treefd7e1f860f28ffa41d33416fb6277a95e530191e /libpanto
parentfbee69ca8c4b485b7b2d6fe7448b13367c4716c8 (diff)
auth: flatten [auth.<name>] config + ${...} substitution
Collapse the auth schema: infer `type` from keys, replace key/key_env_var with a single `key` (resolved via substitution), flatten the exchange to flat `exchange_*` keys, and drop the auth-level headers tables in favor of reusing the provider's `extra_headers` on the auth HTTP calls. Add `${sibling}` and `${env:VAR}` substitution over auth values (GitHub Enterprise = set `domain`). Restore api_key-empty → provider-drop. Update default config, docs, and tests.
Diffstat (limited to 'libpanto')
-rw-r--r--libpanto/src/auth.zig42
1 files changed, 24 insertions, 18 deletions
diff --git a/libpanto/src/auth.zig b/libpanto/src/auth.zig
index 9bf37b4..5b65bf4 100644
--- a/libpanto/src/auth.zig
+++ b/libpanto/src/auth.zig
@@ -43,10 +43,12 @@ 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`.
+/// 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,
- key_env_var: ?[]const u8 = null,
};
/// A secondary token exchange run after the durable OAuth token is obtained
@@ -64,8 +66,6 @@ pub const ExchangeConfig = struct {
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 };
};
@@ -93,8 +93,6 @@ pub const OAuthDeviceAuth = struct {
/// 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,
};
@@ -365,11 +363,14 @@ fn encodeBody(
}
}
-/// Request a device/user code. Allocates the result into `arena`.
+/// 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.
@@ -383,7 +384,7 @@ fn requestDeviceAuth(
};
const resp = try http.request(arena, client, .POST, oauth.device_code_url, .{
- .headers = oauth.headers,
+ .headers = headers,
.body = enc.body,
.content_type = enc.content_type,
});
@@ -429,6 +430,7 @@ fn pollOnce(
client: *std.http.Client,
oauth: OAuthDeviceAuth,
da: DeviceAuth,
+ headers: []const Header,
) !PollResult {
switch (oauth.dialect) {
.token => {
@@ -438,7 +440,7 @@ fn pollOnce(
.{ "grant_type", "urn:ietf:params:oauth:grant-type:device_code" },
});
const resp = try http.request(arena, client, .POST, oauth.token_url, .{
- .headers = oauth.headers,
+ .headers = headers,
.body = enc.body,
.content_type = enc.content_type,
});
@@ -460,7 +462,7 @@ fn pollOnce(
.{ "user_code", da.user_code },
});
const resp = try http.request(arena, client, .POST, poll_url, .{
- .headers = oauth.headers,
+ .headers = headers,
.body = enc.body,
.content_type = enc.content_type,
});
@@ -472,7 +474,7 @@ fn pollOnce(
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);
+ const tokens = try exchangeCode(arena, client, oauth, code, verifier, headers);
return .{ .done = tokens };
},
}
@@ -485,6 +487,7 @@ fn exchangeCode(
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, &.{
@@ -495,7 +498,7 @@ fn exchangeCode(
.{ "redirect_uri", redirect },
});
const resp = try http.request(arena, client, .POST, oauth.token_url, .{
- .headers = oauth.headers,
+ .headers = headers,
.body = enc.body,
.content_type = enc.content_type,
});
@@ -511,6 +514,7 @@ pub fn refreshTokens(
client: *std.http.Client,
oauth: OAuthDeviceAuth,
refresh_token: []const u8,
+ headers: []const Header,
) !OAuthTokens {
const enc = try encodeBody(arena, .form, &.{
.{ "grant_type", "refresh_token" },
@@ -518,7 +522,7 @@ pub fn refreshTokens(
.{ "client_id", oauth.client_id },
});
const resp = try http.request(arena, client, .POST, oauth.token_url, .{
- .headers = oauth.headers,
+ .headers = headers,
.body = enc.body,
.content_type = enc.content_type,
});
@@ -549,8 +553,9 @@ pub fn login(
client: *std.http.Client,
oauth: OAuthDeviceAuth,
presenter: Presenter,
+ headers: []const Header,
) !OAuthTokens {
- const da = try requestDeviceAuth(arena, client, oauth);
+ const da = try requestDeviceAuth(arena, client, oauth, headers);
presenter.deviceCode(.{
.verification_uri = da.verification_uri,
.user_code = da.user_code,
@@ -562,7 +567,7 @@ pub fn login(
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) {
+ const result = pollOnce(arena, client, oauth, da, headers) catch |err| switch (err) {
// A transient transport hiccup mid-poll: keep waiting.
error.AuthFlowFailed => return err,
else => return err,
@@ -587,11 +592,12 @@ pub fn runExchange(
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 (exchange.headers) |h| try hdrs.append(arena, h);
+ 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 });
@@ -781,9 +787,9 @@ test "token storage: save, load, delete round-trip" {
}
test "AuthConfig: api_key variant" {
- const a: AuthConfig = .{ .api_key = .{ .key_env_var = "OPENAI_API_KEY" } };
+ const a: AuthConfig = .{ .api_key = .{ .key = "sk-resolved" } };
try t.expectEqual(AuthType.api_key, @as(AuthType, a));
- try t.expectEqualStrings("OPENAI_API_KEY", a.api_key.key_env_var.?);
+ try t.expectEqualStrings("sk-resolved", a.api_key.key.?);
}
test "AuthConfig: oauth_device defaults" {