atproto utils for zig
zat.dev
atproto
sdk
zig
1//! OAuth client primitives for AT Protocol
2//!
3//! PKCE, DPoP proofs, client assertions, and related helpers
4//! for implementing AT Protocol OAuth flows (based on OAuth 2.1).
5//!
6//! see: https://atproto.com/specs/oauth
7
8const std = @import("std");
9const crypto = std.crypto;
10const Io = std.Io;
11const Allocator = std.mem.Allocator;
12const Keypair = @import("crypto/keypair.zig").Keypair;
13const jwt = @import("crypto/jwt.zig");
14
15fn timestamp(io: Io) i64 {
16 return @intCast(@divFloor(Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_s));
17}
18
19/// create a signed JWT from header and payload JSON strings.
20/// caller owns returned slice.
21pub fn createJwt(allocator: Allocator, header_json: []const u8, payload_json: []const u8, keypair: *const Keypair) ![]u8 {
22 const header_b64 = try jwt.base64UrlEncode(allocator, header_json);
23 defer allocator.free(header_b64);
24
25 const payload_b64 = try jwt.base64UrlEncode(allocator, payload_json);
26 defer allocator.free(payload_b64);
27
28 const signing_input = try std.fmt.allocPrint(allocator, "{s}.{s}", .{ header_b64, payload_b64 });
29 defer allocator.free(signing_input);
30
31 const sig = try keypair.sign(signing_input);
32 const sig_b64 = try jwt.base64UrlEncode(allocator, &sig.bytes);
33 defer allocator.free(sig_b64);
34
35 return std.fmt.allocPrint(allocator, "{s}.{s}", .{ signing_input, sig_b64 });
36}
37
38/// create a DPoP proof JWT per RFC 9449.
39/// htm: HTTP method, htu: target URI, nonce: server-provided DPoP-Nonce,
40/// ath: optional access token hash (base64url-encoded SHA-256).
41pub fn createDpopProof(
42 allocator: Allocator,
43 io: std.Io,
44 keypair: *const Keypair,
45 htm: []const u8,
46 htu: []const u8,
47 nonce: ?[]const u8,
48 ath: ?[]const u8,
49) ![]u8 {
50 const jwk_json = try keypair.jwk(allocator);
51 defer allocator.free(jwk_json);
52
53 const jti = try generateJti(allocator, io);
54 defer allocator.free(jti);
55
56 const alg = @tagName(keypair.algorithm());
57 const now = timestamp(io);
58
59 // header: {"typ":"dpop+jwt","alg":"...","jwk":{...}}
60 const header = try std.fmt.allocPrint(allocator,
61 \\{{"typ":"dpop+jwt","alg":"{s}","jwk":{s}}}
62 , .{ alg, jwk_json });
63 defer allocator.free(header);
64
65 // payload — build with writer for optional fields
66 var aw: std.Io.Writer.Allocating = .init(allocator);
67 defer aw.deinit();
68
69 try aw.writer.print(
70 \\{{"jti":"{s}","htm":"{s}","htu":"{s}","iat":{d}
71 , .{ jti, htm, htu, now });
72
73 if (nonce) |n| {
74 try aw.writer.print(",\"nonce\":\"{s}\"", .{n});
75 }
76 if (ath) |a| {
77 try aw.writer.print(",\"ath\":\"{s}\"", .{a});
78 }
79
80 try aw.writer.writeAll("}");
81
82 return createJwt(allocator, header, aw.written(), keypair);
83}
84
85/// create a private_key_jwt client assertion for token endpoint auth.
86/// client_id: the OAuth client ID, aud: the token endpoint URL.
87pub fn createClientAssertion(
88 allocator: Allocator,
89 io: std.Io,
90 keypair: *const Keypair,
91 client_id: []const u8,
92 aud: []const u8,
93) ![]u8 {
94 const jti = try generateJti(allocator, io);
95 defer allocator.free(jti);
96
97 const kid = try keypair.jwkThumbprint(allocator);
98 defer allocator.free(kid);
99
100 const alg = @tagName(keypair.algorithm());
101 const now = timestamp(io);
102
103 const header = try std.fmt.allocPrint(allocator,
104 \\{{"typ":"JWT","alg":"{s}","kid":"{s}"}}
105 , .{ alg, kid });
106 defer allocator.free(header);
107
108 const payload = try std.fmt.allocPrint(allocator,
109 \\{{"iss":"{s}","sub":"{s}","aud":"{s}","jti":"{s}","iat":{d},"exp":{d}}}
110 , .{ client_id, client_id, aud, jti, now, now + 120 });
111 defer allocator.free(payload);
112
113 return createJwt(allocator, header, payload, keypair);
114}
115
116/// generate a random PKCE code verifier (43 chars, base64url-encoded 32 random bytes).
117/// caller owns returned slice.
118pub fn generatePkceVerifier(allocator: Allocator, io: std.Io) ![]u8 {
119 var random_bytes: [32]u8 = undefined;
120 io.random(&random_bytes);
121 return jwt.base64UrlEncode(allocator, &random_bytes);
122}
123
124/// generate a PKCE S256 challenge from a verifier.
125/// caller owns returned slice.
126pub fn generatePkceChallenge(allocator: Allocator, verifier: []const u8) ![]u8 {
127 var hash: [32]u8 = undefined;
128 crypto.hash.sha2.Sha256.hash(verifier, &hash, .{});
129 return jwt.base64UrlEncode(allocator, &hash);
130}
131
132/// generate a random state parameter (CSRF token).
133/// caller owns returned slice.
134pub fn generateState(allocator: Allocator, io: std.Io) ![]u8 {
135 var random_bytes: [16]u8 = undefined;
136 io.random(&random_bytes);
137 return jwt.base64UrlEncode(allocator, &random_bytes);
138}
139
140/// compute access token hash for DPoP ath claim: base64url(SHA-256(access_token)).
141/// caller owns returned slice.
142pub fn accessTokenHash(allocator: Allocator, access_token: []const u8) ![]u8 {
143 var hash: [32]u8 = undefined;
144 crypto.hash.sha2.Sha256.hash(access_token, &hash, .{});
145 return jwt.base64UrlEncode(allocator, &hash);
146}
147
148/// encode key-value pairs as application/x-www-form-urlencoded.
149/// caller owns returned slice.
150pub fn formEncode(allocator: Allocator, params: []const [2][]const u8) ![]u8 {
151 var aw: std.Io.Writer.Allocating = .init(allocator);
152 errdefer aw.deinit();
153
154 for (params, 0..) |kv, i| {
155 if (i > 0) try aw.writer.writeAll("&");
156 try percentEncode(&aw.writer, kv[0]);
157 try aw.writer.writeAll("=");
158 try percentEncode(&aw.writer, kv[1]);
159 }
160
161 return try aw.toOwnedSlice();
162}
163
164/// format a JWKS JSON containing a single public key.
165/// caller owns returned slice.
166pub fn jwksJson(allocator: Allocator, keypair: *const Keypair) ![]u8 {
167 const jwk_json = try keypair.jwk(allocator);
168 defer allocator.free(jwk_json);
169
170 return std.fmt.allocPrint(allocator,
171 \\{{"keys":[{s}]}}
172 , .{jwk_json});
173}
174
175// --- helpers ---
176
177fn generateJti(allocator: Allocator, io: std.Io) ![]u8 {
178 var random_bytes: [16]u8 = undefined;
179 io.random(&random_bytes);
180 return jwt.base64UrlEncode(allocator, &random_bytes);
181}
182
183fn percentEncode(writer: anytype, input: []const u8) !void {
184 for (input) |c| {
185 if (isUnreserved(c)) {
186 try writer.writeByte(c);
187 } else {
188 try writer.print("%{X:0>2}", .{c});
189 }
190 }
191}
192
193fn isUnreserved(c: u8) bool {
194 return switch (c) {
195 'A'...'Z', 'a'...'z', '0'...'9', '-', '_', '.', '~' => true,
196 else => false,
197 };
198}
199
200// === tests ===
201
202test "PKCE S256 challenge - RFC 7636 test vector" {
203 const allocator = std.testing.allocator;
204 const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
205 const challenge = try generatePkceChallenge(allocator, verifier);
206 defer allocator.free(challenge);
207 try std.testing.expectEqualStrings("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", challenge);
208}
209
210test "PKCE verifier is 43 chars" {
211 const allocator = std.testing.allocator;
212 const io = std.Options.debug_io;
213 const verifier = try generatePkceVerifier(allocator, io);
214 defer allocator.free(verifier);
215 try std.testing.expectEqual(@as(usize, 43), verifier.len);
216}
217
218test "form URL encoding" {
219 const allocator = std.testing.allocator;
220
221 const params = [_][2][]const u8{
222 .{ "grant_type", "authorization_code" },
223 .{ "code", "abc123" },
224 .{ "redirect_uri", "https://example.com/callback" },
225 };
226
227 const encoded = try formEncode(allocator, ¶ms);
228 defer allocator.free(encoded);
229
230 try std.testing.expectEqualStrings(
231 "grant_type=authorization_code&code=abc123&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback",
232 encoded,
233 );
234}
235
236test "access token hash" {
237 const allocator = std.testing.allocator;
238 const ath = try accessTokenHash(allocator, "test-access-token");
239 defer allocator.free(ath);
240 // base64url(SHA-256) is always 43 chars
241 try std.testing.expectEqual(@as(usize, 43), ath.len);
242}
243
244test "createJwt sign and verify round-trip" {
245 const allocator = std.testing.allocator;
246 const multibase = @import("crypto/multibase.zig");
247 const multicodec = @import("crypto/multicodec.zig");
248
249 const keypair = try Keypair.fromSecretKey(.p256, .{
250 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
251 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30,
252 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
253 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40,
254 });
255
256 const header =
257 \\{"alg":"ES256","typ":"JWT"}
258 ;
259 const payload =
260 \\{"iss":"did:example:test","aud":"did:example:aud","exp":9999999999}
261 ;
262
263 const token = try createJwt(allocator, header, payload, &keypair);
264 defer allocator.free(token);
265
266 // parse and verify with existing JWT infrastructure
267 var parsed_jwt = try jwt.Jwt.parse(allocator, token);
268 defer parsed_jwt.deinit();
269
270 try std.testing.expectEqual(jwt.Algorithm.ES256, parsed_jwt.header.alg);
271 try std.testing.expectEqualStrings("did:example:test", parsed_jwt.payload.iss);
272
273 // verify signature via multibase key
274 const pk = try keypair.publicKey();
275 const mc_bytes = try multicodec.encodePublicKey(allocator, .p256, &pk);
276 defer allocator.free(mc_bytes);
277 const multibase_key = try multibase.encode(allocator, .base58btc, mc_bytes);
278 defer allocator.free(multibase_key);
279
280 try parsed_jwt.verify(multibase_key);
281}
282
283test "DPoP proof structure" {
284 const allocator = std.testing.allocator;
285 const io = std.Options.debug_io;
286
287 const keypair = try Keypair.fromSecretKey(.p256, .{
288 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
289 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30,
290 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
291 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40,
292 });
293
294 const proof = try createDpopProof(allocator, io, &keypair, "POST", "https://auth.example.com/token", "server-nonce", null);
295 defer allocator.free(proof);
296
297 // decode header
298 var iter = std.mem.splitScalar(u8, proof, '.');
299 const header_b64 = iter.next().?;
300 const payload_b64 = iter.next().?;
301
302 const header_json = try jwt.base64UrlDecode(allocator, header_b64);
303 defer allocator.free(header_json);
304
305 const header_parsed = try std.json.parseFromSlice(std.json.Value, allocator, header_json, .{});
306 defer header_parsed.deinit();
307
308 try std.testing.expectEqualStrings("dpop+jwt", header_parsed.value.object.get("typ").?.string);
309 try std.testing.expectEqualStrings("ES256", header_parsed.value.object.get("alg").?.string);
310 try std.testing.expect(header_parsed.value.object.get("jwk") != null);
311
312 // decode payload
313 const payload_json = try jwt.base64UrlDecode(allocator, payload_b64);
314 defer allocator.free(payload_json);
315
316 const payload_parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{});
317 defer payload_parsed.deinit();
318
319 const obj = payload_parsed.value.object;
320 try std.testing.expect(obj.get("jti") != null);
321 try std.testing.expectEqualStrings("POST", obj.get("htm").?.string);
322 try std.testing.expectEqualStrings("https://auth.example.com/token", obj.get("htu").?.string);
323 try std.testing.expect(obj.get("iat") != null);
324 try std.testing.expectEqualStrings("server-nonce", obj.get("nonce").?.string);
325}
326
327test "client assertion structure" {
328 const allocator = std.testing.allocator;
329 const io = std.Options.debug_io;
330
331 const keypair = try Keypair.fromSecretKey(.p256, .{
332 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
333 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30,
334 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
335 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40,
336 });
337
338 const assertion = try createClientAssertion(allocator, io, &keypair, "https://app.example.com/client-metadata", "https://bsky.social/oauth/token");
339 defer allocator.free(assertion);
340
341 // decode header
342 var iter = std.mem.splitScalar(u8, assertion, '.');
343 const header_b64 = iter.next().?;
344 const payload_b64 = iter.next().?;
345
346 const header_json = try jwt.base64UrlDecode(allocator, header_b64);
347 defer allocator.free(header_json);
348
349 const header_parsed = try std.json.parseFromSlice(std.json.Value, allocator, header_json, .{});
350 defer header_parsed.deinit();
351
352 try std.testing.expectEqualStrings("JWT", header_parsed.value.object.get("typ").?.string);
353 try std.testing.expectEqualStrings("ES256", header_parsed.value.object.get("alg").?.string);
354 try std.testing.expect(header_parsed.value.object.get("kid") != null);
355
356 // decode payload
357 const payload_json = try jwt.base64UrlDecode(allocator, payload_b64);
358 defer allocator.free(payload_json);
359
360 const payload_parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{});
361 defer payload_parsed.deinit();
362
363 const obj = payload_parsed.value.object;
364 try std.testing.expectEqualStrings("https://app.example.com/client-metadata", obj.get("iss").?.string);
365 try std.testing.expectEqualStrings("https://app.example.com/client-metadata", obj.get("sub").?.string);
366 try std.testing.expectEqualStrings("https://bsky.social/oauth/token", obj.get("aud").?.string);
367 try std.testing.expect(obj.get("jti") != null);
368 try std.testing.expect(obj.get("iat") != null);
369 try std.testing.expect(obj.get("exp") != null);
370}
371
372test "JWKS JSON wraps JWK" {
373 const allocator = std.testing.allocator;
374
375 const keypair = try Keypair.fromSecretKey(.p256, .{
376 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
377 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30,
378 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
379 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40,
380 });
381
382 const jwks = try jwksJson(allocator, &keypair);
383 defer allocator.free(jwks);
384
385 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, jwks, .{});
386 defer parsed.deinit();
387
388 const keys = parsed.value.object.get("keys").?.array;
389 try std.testing.expectEqual(@as(usize, 1), keys.items.len);
390 try std.testing.expectEqualStrings("EC", keys.items[0].object.get("kty").?.string);
391}