├── .gitignore ├── zig.mod ├── gyro.zzz ├── README.md ├── LICENSE └── jwt.zig /.gitignore: -------------------------------------------------------------------------------- 1 | /zig-cache/ 2 | /zig-out/ 3 | /.zig-cache 4 | -------------------------------------------------------------------------------- /zig.mod: -------------------------------------------------------------------------------- 1 | id: 2puqxsisuaodaiftbs1smvidrmlxbofeirmt29lfkh2ybyeo 2 | name: jwt 3 | main: jwt.zig 4 | license: MIT 5 | dependencies: 6 | -------------------------------------------------------------------------------- /gyro.zzz: -------------------------------------------------------------------------------- 1 | pkgs: 2 | jwt: 3 | version: 0.0.1 4 | description: JSON Web Tokens for Zig 5 | license: MIT 6 | source_url: https://github.com/leroycep/zig-jwt 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSON Web Tokens 2 | 3 | This library implement signing and verification of [JSON Web Tokens][], or JWTs, 4 | based on [RFC 7159][]. 5 | 6 | [json web tokens]: https://jwt.io/ 7 | [rfc 7159]: https://datatracker.ietf.org/doc/html/rfc7519 8 | 9 | ## Features 10 | 11 | - [x] [JWS][] tokens 12 | - [ ] [JWE][] tokens 13 | - [x] Sign 14 | - [x] Verify 15 | - [ ] iss check 16 | - [ ] sub check 17 | - [ ] aud check 18 | - [ ] exp check 19 | - [ ] nbf check 20 | - [ ] iat check 21 | - [ ] jti check 22 | - [ ] typ check 23 | 24 | [JWS]: https://datatracker.ietf.org/doc/html/rfc7515 25 | [JWE]: https://datatracker.ietf.org/doc/html/rfc7516 26 | 27 | Encryption algorithms: 28 | 29 | - [x] HS256 30 | - [x] HS384 31 | - [x] HS512 32 | - [ ] PS256 33 | - [ ] PS384 34 | - [ ] PS512 35 | - [ ] RS256 36 | - [ ] RS384 37 | - [ ] RS512 38 | - [ ] ES256 39 | - [ ] ES256K 40 | - [ ] ES384 41 | - [ ] ES512 42 | - [ ] EdDSA 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 LeRoyce Pearson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /jwt.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const testing = std.testing; 3 | const ValueTree = std.json.ValueTree; 4 | const Value = std.json.Value; 5 | const base64url = std.base64.url_safe_no_pad; 6 | 7 | const Algorithm = enum { 8 | const Self = @This(); 9 | 10 | HS256, 11 | HS384, 12 | HS512, 13 | 14 | pub fn jsonStringify(value: Self, options: std.json.StringifyOptions, writer: anytype) @TypeOf(writer).Error!void { 15 | try std.json.stringify(std.meta.tagName(value), options, writer); 16 | } 17 | 18 | pub fn CryptoFn(comptime self: Self) type { 19 | return switch (self) { 20 | .HS256 => std.crypto.auth.hmac.sha2.HmacSha256, 21 | .HS384 => std.crypto.auth.hmac.sha2.HmacSha384, 22 | .HS512 => std.crypto.auth.hmac.sha2.HmacSha512, 23 | }; 24 | } 25 | }; 26 | 27 | const JWTType = enum { 28 | JWS, 29 | JWE, 30 | }; 31 | 32 | pub const SignatureOptions = struct { 33 | key: []const u8, 34 | kid: ?[]const u8 = null, 35 | }; 36 | 37 | pub fn encode(allocator: std.mem.Allocator, comptime alg: Algorithm, payload: anytype, signatureOptions: SignatureOptions) ![]const u8 { 38 | var payload_json = std.Io.Writer.Allocating.init(allocator); 39 | defer payload_json.deinit(); 40 | 41 | var stringify = std.json.Stringify{ 42 | .writer = &payload_json.writer, 43 | .options = .{}, 44 | }; 45 | try stringify.write(payload); 46 | 47 | return try encodeMessage(allocator, alg, payload_json.written(), signatureOptions); 48 | } 49 | 50 | pub fn encodeMessage(allocator: std.mem.Allocator, comptime alg: Algorithm, message: []const u8, signatureOptions: SignatureOptions) ![]const u8 { 51 | var protected_header = std.json.ObjectMap.init(allocator); 52 | defer protected_header.deinit(); 53 | try protected_header.put("alg", .{ .string = @tagName(alg) }); 54 | try protected_header.put("typ", .{ .string = "JWT" }); 55 | if (signatureOptions.kid) |kid| { 56 | try protected_header.put("kid", .{ .string = kid }); 57 | } 58 | 59 | var protected_header_json = std.Io.Writer.Allocating.init(allocator); 60 | defer protected_header_json.deinit(); 61 | 62 | var stringify = std.json.Stringify{ 63 | .writer = &protected_header_json.writer, 64 | .options = .{}, 65 | }; 66 | try stringify.write(Value{ .object = protected_header }); 67 | 68 | const message_base64_len = base64url.Encoder.calcSize(message.len); 69 | const protected_header_base64_len = base64url.Encoder.calcSize(protected_header_json.written().len); 70 | 71 | var jwt_text = std.Io.Writer.Allocating.init(allocator); 72 | defer jwt_text.deinit(); 73 | try jwt_text.ensureTotalCapacity(message_base64_len + 1 + protected_header_base64_len); 74 | 75 | const signature = blk: { 76 | const protected_header_base64 = jwt_text.writer.buffer[0..protected_header_base64_len]; 77 | const message_base64 = jwt_text.writer.buffer[protected_header_base64_len + 1 ..][0..message_base64_len]; 78 | 79 | _ = base64url.Encoder.encode(protected_header_base64, protected_header_json.written()); 80 | jwt_text.writer.buffer[protected_header_base64_len] = '.'; 81 | _ = base64url.Encoder.encode(message_base64, message); 82 | jwt_text.writer.end = protected_header_base64_len + 1 + message_base64_len; 83 | 84 | break :blk generate_signature(alg, signatureOptions.key, protected_header_base64, message_base64); 85 | }; 86 | const signature_base64_len = base64url.Encoder.calcSize(signature.len); 87 | 88 | try jwt_text.ensureTotalCapacity(message_base64_len + 1 + protected_header_base64_len + 1 + signature_base64_len); 89 | const signature_base64 = jwt_text.writer.buffer[message_base64_len + 1 + protected_header_base64_len + 1 ..][0..signature_base64_len]; 90 | 91 | jwt_text.writer.buffer[message_base64_len + 1 + protected_header_base64_len] = '.'; 92 | _ = base64url.Encoder.encode(signature_base64, &signature); 93 | jwt_text.writer.end += 1 + signature_base64_len; 94 | 95 | return jwt_text.toOwnedSlice(); 96 | } 97 | 98 | pub fn validate(comptime P: type, allocator: std.mem.Allocator, comptime alg: Algorithm, tokenText: []const u8, signatureOptions: SignatureOptions) !std.json.Parsed(P) { 99 | const message = try validateMessage(allocator, alg, tokenText, signatureOptions); 100 | defer allocator.free(message); 101 | 102 | // 10. Verify that the resulting octet sequence is a UTF-8-encoded 103 | // representation of a completely valid JSON object conforming to 104 | // RFC 7159 [RFC7159]; let the JWT Claims Set be this JSON object. 105 | return std.json.parseFromSlice(P, allocator, message, .{ .allocate = .alloc_always }); 106 | } 107 | 108 | pub fn validateMessage(allocator: std.mem.Allocator, comptime expectedAlg: Algorithm, tokenText: []const u8, signatureOptions: SignatureOptions) ![]const u8 { 109 | // 1. Verify that the JWT contains at least one period ('.') 110 | // character. 111 | // 2. Let the Encoded JOSE Header be the portion of the JWT before the 112 | // first period ('.') character. 113 | const end_of_jose_base64 = std.mem.indexOfScalar(u8, tokenText, '.') orelse return error.InvalidFormat; 114 | const jose_base64 = tokenText[0..end_of_jose_base64]; 115 | 116 | // 3. Base64url decode the Encoded JOSE Header following the 117 | // restriction that no line breaks, whitespace, or other additional 118 | // characters have been used. 119 | const jose_json = try allocator.alloc(u8, try base64url.Decoder.calcSizeForSlice(jose_base64)); 120 | defer allocator.free(jose_json); 121 | try base64url.Decoder.decode(jose_json, jose_base64); 122 | 123 | // 4. Verify that the resulting octet sequence is a UTF-8-encoded 124 | // representation of a completely valid JSON object conforming to 125 | // RFC 7159 [RFC7159]; let the JOSE Header be this JSON object. 126 | 127 | // TODO: Make sure the JSON parser confirms everything above 128 | 129 | const cty_opt = @as(?[]const u8, null); 130 | defer if (cty_opt) |cty| allocator.free(cty); 131 | 132 | var jwt_tree = try std.json.parseFromSlice(std.json.Value, allocator, jose_json, .{}); 133 | defer jwt_tree.deinit(); 134 | 135 | // 5. Verify that the resulting JOSE Header includes only parameters 136 | // and values whose syntax and semantics are both understood and 137 | // supported or that are specified as being ignored when not 138 | // understood. 139 | 140 | var jwt_root = jwt_tree.value; 141 | if (jwt_root != .object) return error.InvalidFormat; 142 | 143 | { 144 | const alg_val = jwt_root.object.get("alg") orelse return error.InvalidFormat; 145 | if (alg_val != .string) return error.InvalidFormat; 146 | const alg = std.meta.stringToEnum(Algorithm, alg_val.string) orelse return error.InvalidAlgorithm; 147 | 148 | // Make sure that the algorithm matches: https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ 149 | if (alg != expectedAlg) return error.InvalidAlgorithm; 150 | 151 | // TODO: Determine if "jku"/"jwk" need to be parsed and validated 152 | 153 | if (jwt_root.object.get("crit")) |crit_val| { 154 | if (crit_val != .array) return error.InvalidFormat; 155 | const crit = crit_val.array; 156 | if (crit.items.len == 0) return error.InvalidFormat; 157 | 158 | // TODO: Implement or allow extensions? 159 | return error.UnknownExtension; 160 | } 161 | } 162 | 163 | // 6. Determine whether the JWT is a JWS or a JWE using any of the 164 | // methods described in Section 9 of [JWE]. 165 | 166 | const jwt_type = determine_jwt_type: { 167 | // From Section 9 of the JWE specification: 168 | // > o If the object is using the JWS Compact Serialization or the JWE 169 | // > Compact Serialization, the number of base64url-encoded segments 170 | // > separated by period ('.') characters differs for JWSs and JWEs. 171 | // > JWSs have three segments separated by two period ('.') characters. 172 | // > JWEs have five segments separated by four period ('.') characters. 173 | switch (std.mem.count(u8, tokenText, ".")) { 174 | 2 => break :determine_jwt_type JWTType.JWS, 175 | 4 => break :determine_jwt_type JWTType.JWE, 176 | else => return error.InvalidFormat, 177 | } 178 | }; 179 | 180 | // 7. Depending upon whether the JWT is a JWS or JWE, there are two 181 | // cases: 182 | const message_base64 = get_message: { 183 | switch (jwt_type) { 184 | // If the JWT is a JWS, follow the steps specified in [JWS] for 185 | // validating a JWS. Let the Message be the result of base64url 186 | // decoding the JWS Payload. 187 | .JWS => { 188 | var section_iter = std.mem.splitScalar(u8, tokenText, '.'); 189 | std.debug.assert(section_iter.next() != null); 190 | const payload_base64 = section_iter.next().?; 191 | const signature_base64 = section_iter.rest(); 192 | 193 | const signature = try allocator.alloc(u8, try base64url.Decoder.calcSizeForSlice(signature_base64)); 194 | defer allocator.free(signature); 195 | try base64url.Decoder.decode(signature, signature_base64); 196 | 197 | const gen_sig = &generate_signature(expectedAlg, signatureOptions.key, jose_base64, payload_base64); 198 | if (!std.mem.eql(u8, signature, gen_sig)) { 199 | return error.InvalidSignature; 200 | } 201 | 202 | break :get_message try allocator.dupe(u8, payload_base64); 203 | }, 204 | .JWE => { 205 | // Else, if the JWT is a JWE, follow the steps specified in 206 | // [JWE] for validating a JWE. Let the Message be the resulting 207 | // plaintext. 208 | return error.Unimplemented; 209 | }, 210 | } 211 | }; 212 | defer allocator.free(message_base64); 213 | 214 | // 8. If the JOSE Header contains a "cty" (content type) value of 215 | // "JWT", then the Message is a JWT that was the subject of nested 216 | // signing or encryption operations. In this case, return to Step 217 | // 1, using the Message as the JWT. 218 | if (jwt_root.object.get("cty")) |cty_val| { 219 | if (cty_val != .string) return error.InvalidFormat; 220 | return error.Unimplemented; 221 | } 222 | 223 | // 9. Otherwise, base64url decode the Message following the 224 | // restriction that no line breaks, whitespace, or other additional 225 | // characters have been used. 226 | const message = try allocator.alloc(u8, try base64url.Decoder.calcSizeForSlice(message_base64)); 227 | errdefer allocator.free(message); 228 | try base64url.Decoder.decode(message, message_base64); 229 | 230 | return message; 231 | } 232 | 233 | pub fn generate_signature(comptime algo: Algorithm, key: []const u8, protectedHeaderBase64: []const u8, payloadBase64: []const u8) [algo.CryptoFn().mac_length]u8 { 234 | const T = algo.CryptoFn(); 235 | var h = T.init(key); 236 | h.update(protectedHeaderBase64); 237 | h.update("."); 238 | h.update(payloadBase64); 239 | 240 | var out: [T.mac_length]u8 = undefined; 241 | h.final(&out); 242 | 243 | return out; 244 | } 245 | 246 | test "generate jws based tokens" { 247 | const payload: TestPayload = .{ 248 | .sub = "1234567890", 249 | .name = "John Doe", 250 | .iat = 1516239022, 251 | }; 252 | 253 | try test_generate( 254 | .HS256, 255 | payload, 256 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SVT7VUK8eOve-SCacPaU_bkzT3SFr9wk5EQciofG4Qo", 257 | "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow", 258 | ); 259 | try test_generate( 260 | .HS384, 261 | payload, 262 | "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.MSnfJgb61edr7STbvEqi4Mj3Vvmb8Kh3lsnlXacv0cDAGYhBOpNmOrhWwQgTJCKj", 263 | "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow", 264 | ); 265 | try test_generate( 266 | .HS512, 267 | payload, 268 | "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.39Xvky4dIVLaVaOW5BgbO7smTZUyvIcRtBE3i2hVW3GbjSeUFmpwRbMy94CfvgHC3KHT6V4-pnkNTotCWer-cw", 269 | "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow", 270 | ); 271 | } 272 | 273 | test "validate jws based tokens" { 274 | const expected = TestValidatePayload{ 275 | .iss = "joe", 276 | .exp = 1300819380, 277 | .@"http://example.com/is_root" = true, 278 | }; 279 | 280 | try test_validate( 281 | .HS256, 282 | expected, 283 | "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", 284 | "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow", 285 | ); 286 | try test_validate( 287 | .HS384, 288 | expected, 289 | "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.2B5ucfIDtuSVRisXjPwZlqPAwgEicFIX7Gd2r8rlAbLukenHTW0Rbx1ca1VJSyLg", 290 | "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow", 291 | ); 292 | try test_validate( 293 | .HS512, 294 | expected, 295 | "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.TrGchM_jCqCTAYUQlFmXt-KOyKO0O2wYYW5fUSV8jtdgqWJ74cqNA1zc9Ix7TU4qJ-Y32rKmP9Xpu99yiShx6g", 296 | "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow", 297 | ); 298 | } 299 | 300 | test "generate and then validate jws token" { 301 | try test_generate_then_validate(.HS256, .{ .key = "a jws hmac sha-256 test key" }); 302 | try test_generate_then_validate(.HS384, .{ .key = "a jws hmac sha-384 test key" }); 303 | } 304 | 305 | const TestPayload = struct { 306 | sub: []const u8, 307 | name: []const u8, 308 | iat: i64, 309 | }; 310 | 311 | fn test_generate(comptime algorithm: Algorithm, payload: TestPayload, expected: []const u8, key_base64: []const u8) !void { 312 | const key = try std.testing.allocator.alloc(u8, try base64url.Decoder.calcSizeForSlice(key_base64)); 313 | defer std.testing.allocator.free(key); 314 | try base64url.Decoder.decode(key, key_base64); 315 | 316 | const token = try encode(std.testing.allocator, algorithm, payload, .{ .key = key }); 317 | defer std.testing.allocator.free(token); 318 | 319 | try std.testing.expectEqualSlices(u8, expected, token); 320 | } 321 | 322 | const TestValidatePayload = struct { 323 | iss: []const u8, 324 | exp: i64, 325 | @"http://example.com/is_root": bool, 326 | }; 327 | 328 | fn test_validate(comptime algorithm: Algorithm, expected: TestValidatePayload, token: []const u8, key_base64: []const u8) !void { 329 | const key = try std.testing.allocator.alloc(u8, try base64url.Decoder.calcSizeForSlice(key_base64)); 330 | defer std.testing.allocator.free(key); 331 | try base64url.Decoder.decode(key, key_base64); 332 | 333 | var claims_p = try validate(TestValidatePayload, std.testing.allocator, algorithm, token, .{ .key = key }); 334 | defer claims_p.deinit(); 335 | const claims = claims_p.value; 336 | 337 | try std.testing.expectEqualSlices(u8, expected.iss, claims.iss); 338 | try std.testing.expectEqual(expected.exp, claims.exp); 339 | try std.testing.expectEqual(expected.@"http://example.com/is_root", claims.@"http://example.com/is_root"); 340 | } 341 | 342 | fn test_generate_then_validate(comptime alg: Algorithm, signatureOptions: SignatureOptions) !void { 343 | const Payload = struct { 344 | sub: []const u8, 345 | name: []const u8, 346 | iat: i64, 347 | }; 348 | const payload = Payload{ 349 | .sub = "1234567890", 350 | .name = "John Doe", 351 | .iat = 1516239022, 352 | }; 353 | 354 | const token = try encode(std.testing.allocator, alg, payload, signatureOptions); 355 | defer std.testing.allocator.free(token); 356 | errdefer std.debug.print("token = \"{f}\"\n", .{std.zig.fmtString(token)}); 357 | 358 | var decoded_p = try validate(Payload, std.testing.allocator, alg, token, signatureOptions); 359 | defer decoded_p.deinit(); 360 | const decoded = decoded_p.value; 361 | 362 | try std.testing.expectEqualSlices(u8, payload.sub, decoded.sub); 363 | try std.testing.expectEqualSlices(u8, payload.name, decoded.name); 364 | try std.testing.expectEqual(payload.iat, decoded.iat); 365 | } 366 | --------------------------------------------------------------------------------