atproto utils for zig
0
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>

zzstoatzz 40d9de8c c3ab5c06

+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");