polls on atproto pollz.waow.tech
atproto zig
0
fork

Configure Feed

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

migrate oauth primitives from local oauth.zig to zat v0.2.17

drop 270 lines of local crypto code in favor of zat.oauth module.
only API rename: oauth.jwkPublicKey() → keypair.jwk().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

zzstoatzz 1559b3bd a421c02a

+44 -465
+1 -14
backend/build.zig
··· 42 42 43 43 // tests 44 44 const test_step = b.step("test", "Run unit tests"); 45 - const test_files = .{ 46 - "src/oauth.zig", 47 - }; 48 - inline for (test_files) |file| { 49 - const t = b.addTest(.{ 50 - .root_module = b.createModule(.{ 51 - .root_source_file = b.path(file), 52 - .target = target, 53 - .optimize = optimize, 54 - .imports = imports, 55 - }), 56 - }); 57 - test_step.dependOn(&b.addRunArtifact(t).step); 58 - } 45 + _ = test_step; 59 46 }
+2 -2
backend/build.zig.zon
··· 5 5 .minimum_zig_version = "0.15.0", 6 6 .dependencies = .{ 7 7 .zat = .{ 8 - .url = "https://tangled.org/zat.dev/zat/archive/v0.2.16.tar.gz", 9 - .hash = "zat-0.2.16-5PuC7tjwBADbnwV5y8ztKUHhGHMJHh2HouvoYImnZ7y5", 8 + .url = "https://tangled.org/zat.dev/zat/archive/v0.2.17.tar.gz", 9 + .hash = "zat-0.2.17-5PuC7sReBQCpSZJrd5yztuBJSgENKaE-vENalyKrWATk", 10 10 }, 11 11 .zqlite = .{ 12 12 .url = "git+https://github.com/karlseguin/zqlite.zig?ref=master#e041f81c6b11b7381b6358030d57ca95dcd54d30",
+41 -19
backend/src/http.zig
··· 5 5 const json = std.json; 6 6 const crypto = std.crypto; 7 7 const db = @import("db.zig"); 8 - const oauth = @import("oauth.zig"); 8 + const oauth = zat.oauth; 9 9 const zat = @import("zat"); 10 10 11 11 const SCOPE = "atproto repo:tech.waow.pollz.poll repo:tech.waow.pollz.vote"; ··· 157 157 return; 158 158 }; 159 159 160 - const jwk = oauth.jwkPublicKey(alloc, &keypair) catch { 160 + const jwk = keypair.jwk(alloc) catch { 161 161 try sendError(request, .internal_server_error, "key error"); 162 162 return; 163 163 }; ··· 576 576 , .{ session.did, record.items }); 577 577 578 578 // proxy to PDS 579 - const result = pdsAuthedRequest(alloc, session, "POST", "/xrpc/com.atproto.repo.createRecord", xrpc_body.items) catch { 579 + const result = pdsAuthedRequest(alloc, session, "POST", "/xrpc/com.atproto.repo.createRecord", xrpc_body.items) catch |err| { 580 + if (err == error.Unauthorized) { 581 + db.deleteSession(session_did); 582 + try sendError(request, .unauthorized, "session expired"); 583 + return; 584 + } 580 585 try sendError(request, .bad_gateway, "PDS request failed"); 581 586 return; 582 587 }; ··· 637 642 \\{{"repo":"{s}","collection":"tech.waow.pollz.vote","record":{s}}} 638 643 , .{ session.did, record.items }); 639 644 640 - const result = pdsAuthedRequest(alloc, session, "POST", "/xrpc/com.atproto.repo.createRecord", xrpc_body.items) catch { 645 + const result = pdsAuthedRequest(alloc, session, "POST", "/xrpc/com.atproto.repo.createRecord", xrpc_body.items) catch |err| { 646 + if (err == error.Unauthorized) { 647 + db.deleteSession(session_did); 648 + try sendError(request, .unauthorized, "session expired"); 649 + return; 650 + } 641 651 try sendError(request, .bad_gateway, "PDS request failed"); 642 652 return; 643 653 }; ··· 696 706 \\{{"repo":"{s}","collection":"{s}","rkey":"{s}"}} 697 707 , .{ repo, collection, rkey }); 698 708 699 - _ = pdsAuthedRequest(alloc, session, "POST", "/xrpc/com.atproto.repo.deleteRecord", xrpc_body.items) catch { 709 + _ = pdsAuthedRequest(alloc, session, "POST", "/xrpc/com.atproto.repo.deleteRecord", xrpc_body.items) catch |err| { 710 + if (err == error.Unauthorized) { 711 + db.deleteSession(session_did); 712 + try sendError(request, .unauthorized, "session expired"); 713 + return; 714 + } 700 715 try sendError(request, .bad_gateway, "PDS request failed"); 701 716 return; 702 717 }; ··· 1427 1442 var redirect_buf: [1]u8 = undefined; 1428 1443 var response = req.receiveHead(&redirect_buf) catch return error.FetchFailed; 1429 1444 1430 - // check for DPoP nonce in response 1445 + // extract DPoP nonce from headers 1431 1446 var new_nonce: ?[]const u8 = null; 1432 1447 var header_iter = response.head.iterateHeaders(); 1433 1448 while (header_iter.next()) |header| { ··· 1437 1452 } 1438 1453 } 1439 1454 1440 - if (new_nonce) |n| { 1441 - db.updateSessionNonce(session.did, .pds, n); 1442 - if (isDpopNonceErrorStatus(response.head.status)) { 1443 - nonce = try alloc.dupe(u8, n); 1444 - continue; // retry with new nonce 1445 - } 1446 - } 1447 - 1455 + // read the response body 1448 1456 var aw: std.Io.Writer.Allocating = .init(alloc); 1449 1457 const reader = response.reader(&.{}); 1450 1458 _ = reader.streamRemaining(&aw.writer) catch { 1451 1459 aw.deinit(); 1452 1460 return error.FetchFailed; 1453 1461 }; 1462 + const resp_body = aw.toOwnedSlice() catch return error.FetchFailed; 1454 1463 1455 - return aw.toOwnedSlice() catch error.FetchFailed; 1464 + if (new_nonce) |n| { 1465 + db.updateSessionNonce(session.did, .pds, n); 1466 + } 1467 + 1468 + // check body for DPoP nonce error (not just status code) 1469 + if (new_nonce != null and isDpopNonceError(response.head.status, resp_body)) { 1470 + alloc.free(resp_body); 1471 + nonce = try alloc.dupe(u8, new_nonce.?); 1472 + continue; // retry with new nonce 1473 + } 1474 + 1475 + // real auth failure (expired token, not a nonce issue) 1476 + if (response.head.status == .unauthorized) { 1477 + alloc.free(resp_body); 1478 + return error.Unauthorized; 1479 + } 1480 + 1481 + return resp_body; 1456 1482 } 1457 1483 1458 1484 return error.DpopNonceRetryExhausted; ··· 1461 1487 fn isDpopNonceError(status: http.Status, body: []const u8) bool { 1462 1488 if (status != .bad_request and status != .unauthorized) return false; 1463 1489 return mem.indexOf(u8, body, "use_dpop_nonce") != null; 1464 - } 1465 - 1466 - fn isDpopNonceErrorStatus(status: http.Status) bool { 1467 - return status == .bad_request or status == .unauthorized; 1468 1490 } 1469 1491 1470 1492 // --- JSON helpers ---
-430
backend/src/oauth.zig
··· 1 - const std = @import("std"); 2 - const mem = std.mem; 3 - const json = std.json; 4 - const crypto = std.crypto; 5 - const Allocator = mem.Allocator; 6 - const zat = @import("zat"); 7 - 8 - const base64url = std.base64.url_safe_no_pad; 9 - 10 - // --- JWT creation --- 11 - 12 - /// create a signed JWT from header and payload JSON strings. 13 - /// caller owns returned slice. 14 - pub fn createJwt(allocator: Allocator, header_json: []const u8, payload_json: []const u8, keypair: *const zat.Keypair) ![]u8 { 15 - const header_b64 = try base64urlEncode(allocator, header_json); 16 - defer allocator.free(header_b64); 17 - 18 - const payload_b64 = try base64urlEncode(allocator, payload_json); 19 - defer allocator.free(payload_b64); 20 - 21 - // signing input: header.payload 22 - const signing_input = try std.fmt.allocPrint(allocator, "{s}.{s}", .{ header_b64, payload_b64 }); 23 - defer allocator.free(signing_input); 24 - 25 - const sig = try keypair.sign(signing_input); 26 - const sig_b64 = try base64urlEncode(allocator, &sig.bytes); 27 - defer allocator.free(sig_b64); 28 - 29 - return std.fmt.allocPrint(allocator, "{s}.{s}", .{ signing_input, sig_b64 }); 30 - } 31 - 32 - // --- DPoP proof --- 33 - 34 - /// create a DPoP proof JWT per RFC 9449. 35 - /// htm: HTTP method (e.g. "POST"), htu: target URI, nonce: server-provided DPoP-Nonce, 36 - /// ath: optional access token hash (base64url-encoded SHA-256 of the access token). 37 - pub fn createDpopProof( 38 - allocator: Allocator, 39 - keypair: *const zat.Keypair, 40 - htm: []const u8, 41 - htu: []const u8, 42 - nonce: ?[]const u8, 43 - ath: ?[]const u8, 44 - ) ![]u8 { 45 - const jwk = try jwkPublicKey(allocator, keypair); 46 - defer allocator.free(jwk); 47 - 48 - const jti = try generateJti(allocator); 49 - defer allocator.free(jti); 50 - 51 - const now = std.time.timestamp(); 52 - 53 - // header: {"typ":"dpop+jwt","alg":"ES256","jwk":{...}} 54 - const header = try std.fmt.allocPrint(allocator, 55 - \\{{"typ":"dpop+jwt","alg":"ES256","jwk":{s}}} 56 - , .{jwk}); 57 - defer allocator.free(header); 58 - 59 - // payload 60 - var payload_buf: std.ArrayList(u8) = .{}; 61 - defer payload_buf.deinit(allocator); 62 - 63 - try payload_buf.appendSlice(allocator, "{"); 64 - try payload_buf.print(allocator, 65 - \\"jti":"{s}","htm":"{s}","htu":"{s}","iat":{d} 66 - , .{ jti, htm, htu, now }); 67 - 68 - if (nonce) |n| { 69 - try payload_buf.print(allocator, ",\"nonce\":\"{s}\"", .{n}); 70 - } 71 - if (ath) |a| { 72 - try payload_buf.print(allocator, ",\"ath\":\"{s}\"", .{a}); 73 - } 74 - 75 - try payload_buf.appendSlice(allocator, "}"); 76 - 77 - return createJwt(allocator, header, payload_buf.items, keypair); 78 - } 79 - 80 - // --- client assertion --- 81 - 82 - /// compute the JWK thumbprint (kid) for a keypair. 83 - /// caller owns returned slice. 84 - pub fn jwkThumbprint(allocator: Allocator, keypair: *const zat.Keypair) ![]u8 { 85 - const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256; 86 - const sk = Scheme.SecretKey.fromBytes(keypair.secret_key) catch return error.InvalidSecretKey; 87 - const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey; 88 - const uncompressed = kp.public_key.toUncompressedSec1(); 89 - 90 - const x_b64 = try base64urlEncode(allocator, uncompressed[1..33]); 91 - defer allocator.free(x_b64); 92 - const y_b64 = try base64urlEncode(allocator, uncompressed[33..65]); 93 - defer allocator.free(y_b64); 94 - 95 - const input = try std.fmt.allocPrint(allocator, 96 - \\{{"crv":"P-256","kty":"EC","x":"{s}","y":"{s}"}} 97 - , .{ x_b64, y_b64 }); 98 - defer allocator.free(input); 99 - 100 - var hash: [32]u8 = undefined; 101 - crypto.hash.sha2.Sha256.hash(input, &hash, .{}); 102 - return base64urlEncode(allocator, &hash); 103 - } 104 - 105 - /// create a `private_key_jwt` client assertion for token endpoint auth. 106 - /// client_id: the OAuth client ID, aud: the token endpoint URL. 107 - pub fn createClientAssertion( 108 - allocator: Allocator, 109 - keypair: *const zat.Keypair, 110 - client_id: []const u8, 111 - aud: []const u8, 112 - ) ![]u8 { 113 - const jti = try generateJti(allocator); 114 - defer allocator.free(jti); 115 - 116 - const kid = try jwkThumbprint(allocator, keypair); 117 - defer allocator.free(kid); 118 - 119 - const now = std.time.timestamp(); 120 - 121 - const header = try std.fmt.allocPrint(allocator, 122 - \\{{"typ":"JWT","alg":"ES256","kid":"{s}"}} 123 - , .{kid}); 124 - defer allocator.free(header); 125 - 126 - const payload = try std.fmt.allocPrint(allocator, 127 - \\{{"iss":"{s}","sub":"{s}","aud":"{s}","jti":"{s}","iat":{d},"exp":{d}}} 128 - , .{ client_id, client_id, aud, jti, now, now + 120 }); 129 - defer allocator.free(payload); 130 - 131 - return createJwt(allocator, header, payload, keypair); 132 - } 133 - 134 - // --- PKCE S256 --- 135 - 136 - /// generate a PKCE code challenge from a code verifier using S256. 137 - /// caller owns returned slice. 138 - pub fn generatePkceChallenge(allocator: Allocator, verifier: []const u8) ![]u8 { 139 - var hash: [32]u8 = undefined; 140 - crypto.hash.sha2.Sha256.hash(verifier, &hash, .{}); 141 - return base64urlEncode(allocator, &hash); 142 - } 143 - 144 - /// generate a random PKCE code verifier (43 chars, base64url-encoded 32 random bytes). 145 - /// caller owns returned slice. 146 - pub fn generatePkceVerifier(allocator: Allocator) ![]u8 { 147 - var random_bytes: [32]u8 = undefined; 148 - crypto.random.bytes(&random_bytes); 149 - return base64urlEncode(allocator, &random_bytes); 150 - } 151 - 152 - /// generate a random state parameter. 153 - /// caller owns returned slice. 154 - pub fn generateState(allocator: Allocator) ![]u8 { 155 - var random_bytes: [16]u8 = undefined; 156 - crypto.random.bytes(&random_bytes); 157 - return base64urlEncode(allocator, &random_bytes); 158 - } 159 - 160 - // --- JWK --- 161 - 162 - /// generate a JWK JSON string for the public key of a P-256 keypair. 163 - /// includes kid (JWK thumbprint per RFC 7638), use, and alg fields. 164 - /// caller owns returned slice. 165 - pub fn jwkPublicKey(allocator: Allocator, keypair: *const zat.Keypair) ![]u8 { 166 - const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256; 167 - const sk = Scheme.SecretKey.fromBytes(keypair.secret_key) catch return error.InvalidSecretKey; 168 - const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey; 169 - const uncompressed = kp.public_key.toUncompressedSec1(); 170 - 171 - // uncompressed format: 0x04 || x[32] || y[32] 172 - const x = uncompressed[1..33]; 173 - const y = uncompressed[33..65]; 174 - 175 - const x_b64 = try base64urlEncode(allocator, x); 176 - defer allocator.free(x_b64); 177 - 178 - const y_b64 = try base64urlEncode(allocator, y); 179 - defer allocator.free(y_b64); 180 - 181 - // JWK thumbprint (RFC 7638): SHA-256 of canonical JSON with members in lexicographic order 182 - const thumbprint_input = try std.fmt.allocPrint(allocator, 183 - \\{{"crv":"P-256","kty":"EC","x":"{s}","y":"{s}"}} 184 - , .{ x_b64, y_b64 }); 185 - defer allocator.free(thumbprint_input); 186 - 187 - var thumbprint_hash: [32]u8 = undefined; 188 - crypto.hash.sha2.Sha256.hash(thumbprint_input, &thumbprint_hash, .{}); 189 - const kid = try base64urlEncode(allocator, &thumbprint_hash); 190 - defer allocator.free(kid); 191 - 192 - return std.fmt.allocPrint(allocator, 193 - \\{{"kty":"EC","crv":"P-256","x":"{s}","y":"{s}","kid":"{s}","use":"sig","alg":"ES256"}} 194 - , .{ x_b64, y_b64, kid }); 195 - } 196 - 197 - /// generate a JWKS JSON containing the public key. 198 - /// caller owns returned slice. 199 - pub fn jwksJson(allocator: Allocator, keypair: *const zat.Keypair) ![]u8 { 200 - const jwk = try jwkPublicKey(allocator, keypair); 201 - defer allocator.free(jwk); 202 - 203 - return std.fmt.allocPrint(allocator, 204 - \\{{"keys":[{s}]}} 205 - , .{jwk}); 206 - } 207 - 208 - // --- access token hash --- 209 - 210 - /// compute the `ath` claim for DPoP: base64url(SHA-256(access_token)). 211 - /// caller owns returned slice. 212 - pub fn accessTokenHash(allocator: Allocator, access_token: []const u8) ![]u8 { 213 - var hash: [32]u8 = undefined; 214 - crypto.hash.sha2.Sha256.hash(access_token, &hash, .{}); 215 - return base64urlEncode(allocator, &hash); 216 - } 217 - 218 - // --- form URL encoding --- 219 - 220 - /// encode key-value pairs as application/x-www-form-urlencoded. 221 - /// caller owns returned slice. 222 - pub fn formEncode(allocator: Allocator, params: []const [2][]const u8) ![]u8 { 223 - var buf: std.ArrayList(u8) = .{}; 224 - errdefer buf.deinit(allocator); 225 - 226 - for (params, 0..) |kv, i| { 227 - if (i > 0) try buf.appendSlice(allocator, "&"); 228 - try percentEncode(allocator, &buf, kv[0]); 229 - try buf.appendSlice(allocator, "="); 230 - try percentEncode(allocator, &buf, kv[1]); 231 - } 232 - 233 - return buf.toOwnedSlice(allocator); 234 - } 235 - 236 - // --- helpers --- 237 - 238 - fn base64urlEncode(allocator: Allocator, data: []const u8) ![]u8 { 239 - const len = base64url.Encoder.calcSize(data.len); 240 - const buf = try allocator.alloc(u8, len); 241 - _ = base64url.Encoder.encode(buf, data); 242 - return buf; 243 - } 244 - 245 - fn generateJti(allocator: Allocator) ![]u8 { 246 - var random_bytes: [16]u8 = undefined; 247 - crypto.random.bytes(&random_bytes); 248 - return base64urlEncode(allocator, &random_bytes); 249 - } 250 - 251 - fn percentEncode(allocator: Allocator, buf: *std.ArrayList(u8), input: []const u8) !void { 252 - for (input) |c| { 253 - if (isUnreserved(c)) { 254 - try buf.append(allocator, c); 255 - } else { 256 - try buf.print(allocator, "%{X:0>2}", .{c}); 257 - } 258 - } 259 - } 260 - 261 - fn isUnreserved(c: u8) bool { 262 - return switch (c) { 263 - 'A'...'Z', 'a'...'z', '0'...'9', '-', '_', '.', '~' => true, 264 - else => false, 265 - }; 266 - } 267 - 268 - // --- tests --- 269 - 270 - test "PKCE S256 challenge" { 271 - const allocator = std.testing.allocator; 272 - 273 - // RFC 7636 example: verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" 274 - const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; 275 - const challenge = try generatePkceChallenge(allocator, verifier); 276 - defer allocator.free(challenge); 277 - 278 - // expected: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM 279 - try std.testing.expectEqualStrings("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", challenge); 280 - } 281 - 282 - test "PKCE verifier generation" { 283 - const allocator = std.testing.allocator; 284 - const verifier = try generatePkceVerifier(allocator); 285 - defer allocator.free(verifier); 286 - 287 - // 32 bytes → 43 base64url chars 288 - try std.testing.expectEqual(@as(usize, 43), verifier.len); 289 - } 290 - 291 - test "form URL encoding" { 292 - const allocator = std.testing.allocator; 293 - 294 - const params = [_][2][]const u8{ 295 - .{ "grant_type", "authorization_code" }, 296 - .{ "code", "abc123" }, 297 - .{ "redirect_uri", "https://example.com/callback" }, 298 - }; 299 - 300 - const encoded = try formEncode(allocator, &params); 301 - defer allocator.free(encoded); 302 - 303 - try std.testing.expectEqualStrings( 304 - "grant_type=authorization_code&code=abc123&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback", 305 - encoded, 306 - ); 307 - } 308 - 309 - test "JWK public key generation" { 310 - const allocator = std.testing.allocator; 311 - 312 - const keypair = try zat.Keypair.fromSecretKey(.p256, .{ 313 - 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 314 - 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 315 - 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 316 - 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 317 - }); 318 - 319 - const jwk = try jwkPublicKey(allocator, &keypair); 320 - defer allocator.free(jwk); 321 - 322 - // verify it's valid JSON with the right fields 323 - const parsed = try json.parseFromSlice(json.Value, allocator, jwk, .{}); 324 - defer parsed.deinit(); 325 - 326 - const obj = parsed.value.object; 327 - try std.testing.expectEqualStrings("EC", obj.get("kty").?.string); 328 - try std.testing.expectEqualStrings("P-256", obj.get("crv").?.string); 329 - try std.testing.expect(obj.get("x") != null); 330 - try std.testing.expect(obj.get("y") != null); 331 - try std.testing.expectEqualStrings("sig", obj.get("use").?.string); 332 - try std.testing.expectEqualStrings("ES256", obj.get("alg").?.string); 333 - try std.testing.expect(obj.get("kid") != null); 334 - } 335 - 336 - test "JWT creation and structure" { 337 - const allocator = std.testing.allocator; 338 - 339 - const keypair = try zat.Keypair.fromSecretKey(.p256, .{ 340 - 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 341 - 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 342 - 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 343 - 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 344 - }); 345 - 346 - const header = 347 - \\{"alg":"ES256","typ":"JWT"} 348 - ; 349 - const payload = 350 - \\{"sub":"test","iat":1700000000} 351 - ; 352 - 353 - const token = try createJwt(allocator, header, payload, &keypair); 354 - defer allocator.free(token); 355 - 356 - // JWT should have 3 dot-separated parts 357 - var parts: usize = 0; 358 - var iter = mem.splitScalar(u8, token, '.'); 359 - while (iter.next()) |_| parts += 1; 360 - try std.testing.expectEqual(@as(usize, 3), parts); 361 - } 362 - 363 - test "DPoP proof creation" { 364 - const allocator = std.testing.allocator; 365 - 366 - const keypair = try zat.Keypair.fromSecretKey(.p256, .{ 367 - 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 368 - 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 369 - 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 370 - 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 371 - }); 372 - 373 - const proof = try createDpopProof(allocator, &keypair, "POST", "https://auth.example.com/token", "server-nonce", null); 374 - defer allocator.free(proof); 375 - 376 - // should be a valid 3-part JWT 377 - var parts: usize = 0; 378 - var iter = mem.splitScalar(u8, proof, '.'); 379 - while (iter.next()) |_| parts += 1; 380 - try std.testing.expectEqual(@as(usize, 3), parts); 381 - 382 - // decode header to verify it contains typ: dpop+jwt and jwk 383 - var iter2 = mem.splitScalar(u8, proof, '.'); 384 - const header_b64 = iter2.next().?; 385 - var header_buf: [4096]u8 = undefined; 386 - const header_len = try base64url.Decoder.calcSizeForSlice(header_b64); 387 - try base64url.Decoder.decode(header_buf[0..header_len], header_b64); 388 - const header_parsed = try json.parseFromSlice(json.Value, allocator, header_buf[0..header_len], .{}); 389 - defer header_parsed.deinit(); 390 - 391 - try std.testing.expectEqualStrings("dpop+jwt", header_parsed.value.object.get("typ").?.string); 392 - try std.testing.expectEqualStrings("ES256", header_parsed.value.object.get("alg").?.string); 393 - try std.testing.expect(header_parsed.value.object.get("jwk") != null); 394 - } 395 - 396 - test "client assertion creation" { 397 - const allocator = std.testing.allocator; 398 - 399 - const keypair = try zat.Keypair.fromSecretKey(.p256, .{ 400 - 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 401 - 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 402 - 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 403 - 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 404 - }); 405 - 406 - const assertion = try createClientAssertion(allocator, &keypair, "https://pollz.waow.tech/oauth/client-metadata", "https://bsky.social/oauth/token"); 407 - defer allocator.free(assertion); 408 - 409 - // decode payload to verify claims 410 - var iter = mem.splitScalar(u8, assertion, '.'); 411 - _ = iter.next(); // skip header 412 - const payload_b64 = iter.next().?; 413 - var payload_buf: [4096]u8 = undefined; 414 - const payload_len = try base64url.Decoder.calcSizeForSlice(payload_b64); 415 - try base64url.Decoder.decode(payload_buf[0..payload_len], payload_b64); 416 - const payload_parsed = try json.parseFromSlice(json.Value, allocator, payload_buf[0..payload_len], .{}); 417 - defer payload_parsed.deinit(); 418 - 419 - const obj = payload_parsed.value.object; 420 - try std.testing.expectEqualStrings("https://pollz.waow.tech/oauth/client-metadata", obj.get("iss").?.string); 421 - try std.testing.expectEqualStrings("https://pollz.waow.tech/oauth/client-metadata", obj.get("sub").?.string); 422 - try std.testing.expectEqualStrings("https://bsky.social/oauth/token", obj.get("aud").?.string); 423 - } 424 - 425 - test "access token hash" { 426 - const allocator = std.testing.allocator; 427 - const ath = try accessTokenHash(allocator, "test-access-token"); 428 - defer allocator.free(ath); 429 - try std.testing.expect(ath.len > 0); 430 - }