1//! JWT parsing and verification for AT Protocol
2//!
3//! parses and verifies JWTs used in AT Protocol service auth.
4//! supports ES256 (P-256) and ES256K (secp256k1) signing.
5//!
6//! see: https://atproto.com/specs/xrpc#service-auth
7
8const std = @import("std");
9const crypto = std.crypto;
10const json = @import("json.zig");
11const multibase = @import("multibase.zig");
12const multicodec = @import("multicodec.zig");
13
14/// JWT signing algorithm
15pub const Algorithm = enum {
16 ES256, // P-256 / secp256r1
17 ES256K, // secp256k1
18
19 pub fn fromString(s: []const u8) ?Algorithm {
20 if (std.mem.eql(u8, s, "ES256")) return .ES256;
21 if (std.mem.eql(u8, s, "ES256K")) return .ES256K;
22 return null;
23 }
24};
25
26/// parsed JWT header
27pub const Header = struct {
28 alg: Algorithm,
29 typ: []const u8,
30};
31
32/// parsed JWT payload (AT Protocol service auth claims)
33pub const Payload = struct {
34 /// issuer DID (account making the request)
35 iss: []const u8,
36 /// audience DID (service receiving the request)
37 aud: []const u8,
38 /// expiration timestamp (unix seconds)
39 exp: i64,
40 /// issued-at timestamp (unix seconds)
41 iat: ?i64 = null,
42 /// unique nonce for replay prevention
43 jti: ?[]const u8 = null,
44 /// lexicon method (optional, may become required)
45 lxm: ?[]const u8 = null,
46};
47
48/// parsed JWT with raw components
49pub const Jwt = struct {
50 allocator: std.mem.Allocator,
51
52 /// decoded header
53 header: Header,
54 /// decoded payload
55 payload: Payload,
56 /// raw signature bytes (r || s, 64 bytes)
57 signature: []u8,
58 /// the signed portion (header.payload) for verification
59 signed_input: []const u8,
60 /// original token for reference
61 raw_token: []const u8,
62
63 /// parse a JWT token string
64 pub fn parse(allocator: std.mem.Allocator, token: []const u8) !Jwt {
65 // split on dots: header.payload.signature
66 var parts: [3][]const u8 = undefined;
67 var part_idx: usize = 0;
68 var it = std.mem.splitScalar(u8, token, '.');
69
70 while (it.next()) |part| {
71 if (part_idx >= 3) return error.InvalidJwt;
72 parts[part_idx] = part;
73 part_idx += 1;
74 }
75
76 if (part_idx != 3) return error.InvalidJwt;
77
78 const header_b64 = parts[0];
79 const payload_b64 = parts[1];
80 const sig_b64 = parts[2];
81
82 // find signed input (everything before last dot)
83 const last_dot = std.mem.lastIndexOfScalar(u8, token, '.') orelse return error.InvalidJwt;
84 const signed_input = token[0..last_dot];
85
86 // decode header
87 const header_json = try base64UrlDecode(allocator, header_b64);
88 defer allocator.free(header_json);
89
90 const header = try parseHeader(allocator, header_json);
91
92 // decode payload
93 const payload_json = try base64UrlDecode(allocator, payload_b64);
94 defer allocator.free(payload_json);
95
96 const payload = try parsePayload(allocator, payload_json);
97
98 // decode signature
99 const signature = try base64UrlDecode(allocator, sig_b64);
100 errdefer allocator.free(signature);
101
102 // JWT signatures should be 64 bytes (r || s)
103 if (signature.len != 64) {
104 allocator.free(signature);
105 return error.InvalidSignatureLength;
106 }
107
108 return .{
109 .allocator = allocator,
110 .header = header,
111 .payload = payload,
112 .signature = signature,
113 .signed_input = signed_input,
114 .raw_token = token,
115 };
116 }
117
118 /// verify the JWT signature against a public key
119 /// public_key should be multibase-encoded (from DID document)
120 pub fn verify(self: *const Jwt, public_key_multibase: []const u8) !void {
121 // decode multibase key
122 const key_bytes = try multibase.decode(self.allocator, public_key_multibase);
123 defer self.allocator.free(key_bytes);
124
125 // parse multicodec to get key type and raw bytes
126 const parsed_key = try multicodec.parsePublicKey(key_bytes);
127
128 // verify key type matches algorithm
129 switch (self.header.alg) {
130 .ES256K => {
131 if (parsed_key.key_type != .secp256k1) return error.AlgorithmKeyMismatch;
132 try verifySecp256k1(self.signed_input, self.signature, parsed_key.raw);
133 },
134 .ES256 => {
135 if (parsed_key.key_type != .p256) return error.AlgorithmKeyMismatch;
136 try verifyP256(self.signed_input, self.signature, parsed_key.raw);
137 },
138 }
139 }
140
141 /// check if the token is expired
142 pub fn isExpired(self: *const Jwt) bool {
143 const now = std.time.timestamp();
144 return now > self.payload.exp;
145 }
146
147 /// check if the token is expired with clock skew tolerance (in seconds)
148 pub fn isExpiredWithSkew(self: *const Jwt, skew_seconds: i64) bool {
149 const now = std.time.timestamp();
150 return now > (self.payload.exp + skew_seconds);
151 }
152
153 pub fn deinit(self: *Jwt) void {
154 self.allocator.free(self.signature);
155 self.allocator.free(self.payload.iss);
156 self.allocator.free(self.payload.aud);
157 if (self.payload.jti) |s| self.allocator.free(s);
158 if (self.payload.lxm) |s| self.allocator.free(s);
159 }
160};
161
162// === internal helpers ===
163
164fn base64UrlDecode(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
165 const decoder = &std.base64.url_safe_no_pad.Decoder;
166 const size = try decoder.calcSizeForSlice(input);
167 const output = try allocator.alloc(u8, size);
168 errdefer allocator.free(output);
169 try decoder.decode(output, input);
170 return output;
171}
172
173fn parseHeader(allocator: std.mem.Allocator, header_json: []const u8) !Header {
174 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, header_json, .{});
175 defer parsed.deinit();
176
177 const alg_str = json.getString(parsed.value, "alg") orelse return error.MissingAlgorithm;
178 const alg = Algorithm.fromString(alg_str) orelse return error.UnsupportedAlgorithm;
179
180 return .{
181 .alg = alg,
182 .typ = "JWT", // static string, no need to dupe
183 };
184}
185
186fn parsePayload(allocator: std.mem.Allocator, payload_json: []const u8) !Payload {
187 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{});
188 defer parsed.deinit();
189
190 const iss_raw = json.getString(parsed.value, "iss") orelse return error.MissingIssuer;
191 const aud_raw = json.getString(parsed.value, "aud") orelse return error.MissingAudience;
192 const exp = json.getInt(parsed.value, "exp") orelse return error.MissingExpiration;
193
194 // dupe strings so they outlive parsed
195 const iss = try allocator.dupe(u8, iss_raw);
196 errdefer allocator.free(iss);
197
198 const aud = try allocator.dupe(u8, aud_raw);
199 errdefer allocator.free(aud);
200
201 const jti: ?[]const u8 = if (json.getString(parsed.value, "jti")) |s|
202 try allocator.dupe(u8, s)
203 else
204 null;
205 errdefer if (jti) |s| allocator.free(s);
206
207 const lxm: ?[]const u8 = if (json.getString(parsed.value, "lxm")) |s|
208 try allocator.dupe(u8, s)
209 else
210 null;
211
212 return .{
213 .iss = iss,
214 .aud = aud,
215 .exp = exp,
216 .iat = json.getInt(parsed.value, "iat"),
217 .jti = jti,
218 .lxm = lxm,
219 };
220}
221
222fn verifySecp256k1(message: []const u8, sig_bytes: []const u8, public_key_raw: []const u8) !void {
223 const Scheme = crypto.sign.ecdsa.EcdsaSecp256k1Sha256;
224
225 // parse signature (r || s, 64 bytes)
226 if (sig_bytes.len != 64) return error.InvalidSignature;
227 const sig = Scheme.Signature.fromBytes(sig_bytes[0..64].*);
228
229 // parse public key from SEC1 compressed format
230 if (public_key_raw.len != 33) return error.InvalidPublicKey;
231 const public_key = Scheme.PublicKey.fromSec1(public_key_raw) catch return error.InvalidPublicKey;
232
233 // verify
234 sig.verify(message, public_key) catch return error.SignatureVerificationFailed;
235}
236
237fn verifyP256(message: []const u8, sig_bytes: []const u8, public_key_raw: []const u8) !void {
238 const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256;
239
240 // parse signature (r || s, 64 bytes)
241 if (sig_bytes.len != 64) return error.InvalidSignature;
242 const sig = Scheme.Signature.fromBytes(sig_bytes[0..64].*);
243
244 // parse public key from SEC1 compressed format
245 if (public_key_raw.len != 33) return error.InvalidPublicKey;
246 const public_key = Scheme.PublicKey.fromSec1(public_key_raw) catch return error.InvalidPublicKey;
247
248 // verify
249 sig.verify(message, public_key) catch return error.SignatureVerificationFailed;
250}
251
252// === tests ===
253
254test "parse jwt structure" {
255 // a minimal valid JWT structure (signature won't verify, just testing parsing)
256 // header: {"alg":"ES256K","typ":"JWT"}
257 // payload: {"iss":"did:plc:test","aud":"did:plc:service","exp":9999999999}
258 const token = "eyJhbGciOiJFUzI1NksiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJkaWQ6cGxjOnRlc3QiLCJhdWQiOiJkaWQ6cGxjOnNlcnZpY2UiLCJleHAiOjk5OTk5OTk5OTl9.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
259
260 var jwt = try Jwt.parse(std.testing.allocator, token);
261 defer jwt.deinit();
262
263 try std.testing.expectEqual(Algorithm.ES256K, jwt.header.alg);
264 try std.testing.expectEqualStrings("did:plc:test", jwt.payload.iss);
265 try std.testing.expectEqualStrings("did:plc:service", jwt.payload.aud);
266 try std.testing.expectEqual(@as(i64, 9999999999), jwt.payload.exp);
267}
268
269test "reject invalid jwt format" {
270 // missing parts
271 try std.testing.expectError(error.InvalidJwt, Jwt.parse(std.testing.allocator, "onlyonepart"));
272 try std.testing.expectError(error.InvalidJwt, Jwt.parse(std.testing.allocator, "two.parts"));
273 try std.testing.expectError(error.InvalidJwt, Jwt.parse(std.testing.allocator, "too.many.parts.here"));
274}
275
276test "verify ES256K signature - official fixture" {
277 // test vector from bluesky-social/indigo atproto/auth/jwt_test.go
278 // pubkey: did:key:zQ3shscXNYZQZSPwegiv7uQZZV5kzATLBRtgJhs7uRY7pfSk4
279 // iss: did:example:iss, aud: did:example:aud, exp: 1713571012
280 const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3MiLCJhdWQiOiJkaWQ6ZXhhbXBsZTphdWQiLCJleHAiOjE3MTM1NzEwMTJ9.J_In_PQCMjygeeoIKyjybORD89ZnEy1bZTd--sdq_78qv3KCO9181ZAh-2Pl0qlXZjfUlxgIa6wiak2NtsT98g";
281
282 // extract multibase key from did:key (strip "did:key:" prefix)
283 const did_key = "did:key:zQ3shscXNYZQZSPwegiv7uQZZV5kzATLBRtgJhs7uRY7pfSk4";
284 const multibase_key = did_key["did:key:".len..];
285
286 var jwt = try Jwt.parse(std.testing.allocator, token);
287 defer jwt.deinit();
288
289 // verify claims
290 try std.testing.expectEqual(Algorithm.ES256K, jwt.header.alg);
291 try std.testing.expectEqualStrings("did:example:iss", jwt.payload.iss);
292 try std.testing.expectEqualStrings("did:example:aud", jwt.payload.aud);
293
294 // verify signature
295 try jwt.verify(multibase_key);
296}
297
298test "verify ES256 signature - official fixture" {
299 // test vector from bluesky-social/indigo atproto/auth/jwt_test.go
300 // pubkey: did:key:zDnaeXRDKRCEUoYxi8ZJS2pDsgfxUh3pZiu3SES9nbY4DoART
301 // iss: did:example:iss, aud: did:example:aud, exp: 1713571554
302 const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3MiLCJhdWQiOiJkaWQ6ZXhhbXBsZTphdWQiLCJleHAiOjE3MTM1NzE1NTR9.FFRLm7SGbDUp6cL0WoCs0L5oqNkjCXB963TqbgI-KxIjbiqMQATVCalcMJx17JGTjMmfVHJP6Op_V4Z0TTjqog";
303
304 // extract multibase key from did:key
305 const did_key = "did:key:zDnaeXRDKRCEUoYxi8ZJS2pDsgfxUh3pZiu3SES9nbY4DoART";
306 const multibase_key = did_key["did:key:".len..];
307
308 var jwt = try Jwt.parse(std.testing.allocator, token);
309 defer jwt.deinit();
310
311 // verify claims
312 try std.testing.expectEqual(Algorithm.ES256, jwt.header.alg);
313 try std.testing.expectEqualStrings("did:example:iss", jwt.payload.iss);
314 try std.testing.expectEqualStrings("did:example:aud", jwt.payload.aud);
315
316 // verify signature
317 try jwt.verify(multibase_key);
318}
319
320test "reject signature with wrong key" {
321 // ES256K token
322 const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3MiLCJhdWQiOiJkaWQ6ZXhhbXBsZTphdWQiLCJleHAiOjE3MTM1NzEwMTJ9.J_In_PQCMjygeeoIKyjybORD89ZnEy1bZTd--sdq_78qv3KCO9181ZAh-2Pl0qlXZjfUlxgIa6wiak2NtsT98g";
323
324 // different ES256K key (second fixture from indigo)
325 const wrong_key = "zQ3shqKrpHzQ5HDfhgcYMWaFcpBK3SS39wZLdTjA5GeakX8G5";
326
327 var jwt = try Jwt.parse(std.testing.allocator, token);
328 defer jwt.deinit();
329
330 // should fail verification with wrong key
331 try std.testing.expectError(error.SignatureVerificationFailed, jwt.verify(wrong_key));
332}