summaryrefslogtreecommitdiff
path: root/libpanto/src/auth.zig
blob: 012e495afe85a2525b6a374bcac2516ee57ac21a (plain)
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
//! 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 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 = &.{},
};

const t = std.testing;

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);
}