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.

add OAuth client primitives and Keypair JWK methods

Keypair gains uncompressedPublicKey(), jwk(), jwkThumbprint() for both
P-256 and secp256k1. New oauth module provides stateless PKCE, DPoP,
client assertion, and form encoding helpers extracted from pollz.

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

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