atproto utils for zig zat.dev
atproto sdk zig
26
fork

Configure Feed

Select the types of activity you want to include in your feed.

at dfd015c4e12850beb5b43c8b301c91bc8ef0f877 391 lines 14 kB view raw
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, &params); 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}