├── .gitignore ├── LICENSE ├── README.md ├── build.zig ├── build.zig.zon ├── gyro.zzz ├── jwt.zig └── zig.mod /.gitignore: -------------------------------------------------------------------------------- 1 | /zig-cache/ 2 | /zig-out/ 3 | /.zig-cache 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub fn build(b: *std.Build) void { 4 | const target = b.standardTargetOptions(.{}); 5 | const optimize = b.standardOptimizeOption(.{}); 6 | 7 | _ = b.addModule("jwt", .{ 8 | .root_source_file = b.path("jwt.zig"), 9 | }); 10 | 11 | const lib = b.addStaticLibrary(.{ 12 | .name = "jwt", 13 | .root_source_file = b.path("jwt.zig"), 14 | .optimize = optimize, 15 | .target = target, 16 | }); 17 | b.installArtifact(lib); 18 | 19 | const main_tests = b.addTest(.{ 20 | .root_source_file = b.path("jwt.zig"), 21 | }); 22 | const run_main_tests = b.addRunArtifact(main_tests); 23 | 24 | const test_step = b.step("test", "Run library tests"); 25 | test_step.dependOn(&run_main_tests.step); 26 | } 27 | -------------------------------------------------------------------------------- /build.zig.zon: -------------------------------------------------------------------------------- 1 | .{ 2 | .name = .jwt, 3 | .fingerprint = 0x8d17cdf0bb8eb51d, 4 | .version = "0.0.1", 5 | .paths = .{ 6 | "build.zig", 7 | "build.zig.zon", 8 | "jwt.zig", 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.ArrayList(u8).init(allocator); 39 | defer payload_json.deinit(); 40 | 41 | try std.json.stringify(payload, .{}, payload_json.writer()); 42 | 43 | return try encodeMessage(allocator, alg, payload_json.items, signatureOptions); 44 | } 45 | 46 | pub fn encodeMessage(allocator: std.mem.Allocator, comptime alg: Algorithm, message: []const u8, signatureOptions: SignatureOptions) ![]const u8 { 47 | var protected_header = std.json.ObjectMap.init(allocator); 48 | defer protected_header.deinit(); 49 | try protected_header.put("alg", .{ .string = @tagName(alg) }); 50 | try protected_header.put("typ", .{ .string = "JWT" }); 51 | if (signatureOptions.kid) |kid| { 52 | try protected_header.put("kid", .{ .string = kid }); 53 | } 54 | 55 | var protected_header_json = std.ArrayList(u8).init(allocator); 56 | defer protected_header_json.deinit(); 57 | 58 | try std.json.stringify(Value{ .object = protected_header }, .{}, protected_header_json.writer()); 59 | 60 | const message_base64_len = base64url.Encoder.calcSize(message.len); 61 | const protected_header_base64_len = base64url.Encoder.calcSize(protected_header_json.items.len); 62 | 63 | var jwt_text = std.ArrayList(u8).init(allocator); 64 | defer jwt_text.deinit(); 65 | try jwt_text.resize(message_base64_len + 1 + protected_header_base64_len); 66 | 67 | const protected_header_base64 = jwt_text.items[0..protected_header_base64_len]; 68 | const message_base64 = jwt_text.items[protected_header_base64_len + 1 ..][0..message_base64_len]; 69 | 70 | _ = base64url.Encoder.encode(protected_header_base64, protected_header_json.items); 71 | jwt_text.items[protected_header_base64_len] = '.'; 72 | _ = base64url.Encoder.encode(message_base64, message); 73 | 74 | const signature = &generate_signature(alg, signatureOptions.key, protected_header_base64, message_base64); 75 | const signature_base64_len = base64url.Encoder.calcSize(signature.len); 76 | 77 | try jwt_text.resize(message_base64_len + 1 + protected_header_base64_len + 1 + signature_base64_len); 78 | const signature_base64 = jwt_text.items[message_base64_len + 1 + protected_header_base64_len + 1 ..][0..signature_base64_len]; 79 | 80 | jwt_text.items[message_base64_len + 1 + protected_header_base64_len] = '.'; 81 | _ = base64url.Encoder.encode(signature_base64, signature); 82 | 83 | return jwt_text.toOwnedSlice(); 84 | } 85 | 86 | pub fn validate(comptime P: type, allocator: std.mem.Allocator, comptime alg: Algorithm, tokenText: []const u8, signatureOptions: SignatureOptions) !std.json.Parsed(P) { 87 | const message = try validateMessage(allocator, alg, tokenText, signatureOptions); 88 | defer allocator.free(message); 89 | 90 | // 10. Verify that the resulting octet sequence is a UTF-8-encoded 91 | // representation of a completely valid JSON object conforming to 92 | // RFC 7159 [RFC7159]; let the JWT Claims Set be this JSON object. 93 | return std.json.parseFromSlice(P, allocator, message, .{ .allocate = .alloc_always }); 94 | } 95 | 96 | pub fn validateMessage(allocator: std.mem.Allocator, comptime expectedAlg: Algorithm, tokenText: []const u8, signatureOptions: SignatureOptions) ![]const u8 { 97 | // 1. Verify that the JWT contains at least one period ('.') 98 | // character. 99 | // 2. Let the Encoded JOSE Header be the portion of the JWT before the 100 | // first period ('.') character. 101 | const end_of_jose_base64 = std.mem.indexOfScalar(u8, tokenText, '.') orelse return error.InvalidFormat; 102 | const jose_base64 = tokenText[0..end_of_jose_base64]; 103 | 104 | // 3. Base64url decode the Encoded JOSE Header following the 105 | // restriction that no line breaks, whitespace, or other additional 106 | // characters have been used. 107 | const jose_json = try allocator.alloc(u8, try base64url.Decoder.calcSizeForSlice(jose_base64)); 108 | defer allocator.free(jose_json); 109 | try base64url.Decoder.decode(jose_json, jose_base64); 110 | 111 | // 4. Verify that the resulting octet sequence is a UTF-8-encoded 112 | // representation of a completely valid JSON object conforming to 113 | // RFC 7159 [RFC7159]; let the JOSE Header be this JSON object. 114 | 115 | // TODO: Make sure the JSON parser confirms everything above 116 | 117 | const cty_opt = @as(?[]const u8, null); 118 | defer if (cty_opt) |cty| allocator.free(cty); 119 | 120 | var jwt_tree = try std.json.parseFromSlice(std.json.Value, allocator, jose_json, .{}); 121 | defer jwt_tree.deinit(); 122 | 123 | // 5. Verify that the resulting JOSE Header includes only parameters 124 | // and values whose syntax and semantics are both understood and 125 | // supported or that are specified as being ignored when not 126 | // understood. 127 | 128 | var jwt_root = jwt_tree.value; 129 | if (jwt_root != .object) return error.InvalidFormat; 130 | 131 | { 132 | const alg_val = jwt_root.object.get("alg") orelse return error.InvalidFormat; 133 | if (alg_val != .string) return error.InvalidFormat; 134 | const alg = std.meta.stringToEnum(Algorithm, alg_val.string) orelse return error.InvalidAlgorithm; 135 | 136 | // Make sure that the algorithm matches: https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ 137 | if (alg != expectedAlg) return error.InvalidAlgorithm; 138 | 139 | // TODO: Determine if "jku"/"jwk" need to be parsed and validated 140 | 141 | if (jwt_root.object.get("crit")) |crit_val| { 142 | if (crit_val != .array) return error.InvalidFormat; 143 | const crit = crit_val.array; 144 | if (crit.items.len == 0) return error.InvalidFormat; 145 | 146 | // TODO: Implement or allow extensions? 147 | return error.UnknownExtension; 148 | } 149 | } 150 | 151 | // 6. Determine whether the JWT is a JWS or a JWE using any of the 152 | // methods described in Section 9 of [JWE]. 153 | 154 | const jwt_type = determine_jwt_type: { 155 | // From Section 9 of the JWE specification: 156 | // > o If the object is using the JWS Compact Serialization or the JWE 157 | // > Compact Serialization, the number of base64url-encoded segments 158 | // > separated by period ('.') characters differs for JWSs and JWEs. 159 | // > JWSs have three segments separated by two period ('.') characters. 160 | // > JWEs have five segments separated by four period ('.') characters. 161 | switch (std.mem.count(u8, tokenText, ".")) { 162 | 2 => break :determine_jwt_type JWTType.JWS, 163 | 4 => break :determine_jwt_type JWTType.JWE, 164 | else => return error.InvalidFormat, 165 | } 166 | }; 167 | 168 | // 7. Depending upon whether the JWT is a JWS or JWE, there are two 169 | // cases: 170 | const message_base64 = get_message: { 171 | switch (jwt_type) { 172 | // If the JWT is a JWS, follow the steps specified in [JWS] for 173 | // validating a JWS. Let the Message be the result of base64url 174 | // decoding the JWS Payload. 175 | .JWS => { 176 | var section_iter = std.mem.splitScalar(u8, tokenText, '.'); 177 | std.debug.assert(section_iter.next() != null); 178 | const payload_base64 = section_iter.next().?; 179 | const signature_base64 = section_iter.rest(); 180 | 181 | const signature = try allocator.alloc(u8, try base64url.Decoder.calcSizeForSlice(signature_base64)); 182 | defer allocator.free(signature); 183 | try base64url.Decoder.decode(signature, signature_base64); 184 | 185 | const gen_sig = &generate_signature(expectedAlg, signatureOptions.key, jose_base64, payload_base64); 186 | if (!std.mem.eql(u8, signature, gen_sig)) { 187 | return error.InvalidSignature; 188 | } 189 | 190 | break :get_message try allocator.dupe(u8, payload_base64); 191 | }, 192 | .JWE => { 193 | // Else, if the JWT is a JWE, follow the steps specified in 194 | // [JWE] for validating a JWE. Let the Message be the resulting 195 | // plaintext. 196 | return error.Unimplemented; 197 | }, 198 | } 199 | }; 200 | defer allocator.free(message_base64); 201 | 202 | // 8. If the JOSE Header contains a "cty" (content type) value of 203 | // "JWT", then the Message is a JWT that was the subject of nested 204 | // signing or encryption operations. In this case, return to Step 205 | // 1, using the Message as the JWT. 206 | if (jwt_root.object.get("cty")) |cty_val| { 207 | if (cty_val != .string) return error.InvalidFormat; 208 | return error.Unimplemented; 209 | } 210 | 211 | // 9. Otherwise, base64url decode the Message following the 212 | // restriction that no line breaks, whitespace, or other additional 213 | // characters have been used. 214 | const message = try allocator.alloc(u8, try base64url.Decoder.calcSizeForSlice(message_base64)); 215 | errdefer allocator.free(message); 216 | try base64url.Decoder.decode(message, message_base64); 217 | 218 | return message; 219 | } 220 | 221 | pub fn generate_signature(comptime algo: Algorithm, key: []const u8, protectedHeaderBase64: []const u8, payloadBase64: []const u8) [algo.CryptoFn().mac_length]u8 { 222 | const T = algo.CryptoFn(); 223 | var h = T.init(key); 224 | h.update(protectedHeaderBase64); 225 | h.update("."); 226 | h.update(payloadBase64); 227 | 228 | var out: [T.mac_length]u8 = undefined; 229 | h.final(&out); 230 | 231 | return out; 232 | } 233 | 234 | test "generate jws based tokens" { 235 | const payload: TestPayload = .{ 236 | .sub = "1234567890", 237 | .name = "John Doe", 238 | .iat = 1516239022, 239 | }; 240 | 241 | try test_generate( 242 | .HS256, 243 | payload, 244 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SVT7VUK8eOve-SCacPaU_bkzT3SFr9wk5EQciofG4Qo", 245 | "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow", 246 | ); 247 | try test_generate( 248 | .HS384, 249 | payload, 250 | "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.MSnfJgb61edr7STbvEqi4Mj3Vvmb8Kh3lsnlXacv0cDAGYhBOpNmOrhWwQgTJCKj", 251 | "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow", 252 | ); 253 | try test_generate( 254 | .HS512, 255 | payload, 256 | "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.39Xvky4dIVLaVaOW5BgbO7smTZUyvIcRtBE3i2hVW3GbjSeUFmpwRbMy94CfvgHC3KHT6V4-pnkNTotCWer-cw", 257 | "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow", 258 | ); 259 | } 260 | 261 | test "validate jws based tokens" { 262 | const expected = TestValidatePayload{ 263 | .iss = "joe", 264 | .exp = 1300819380, 265 | .@"http://example.com/is_root" = true, 266 | }; 267 | 268 | try test_validate( 269 | .HS256, 270 | expected, 271 | "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", 272 | "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow", 273 | ); 274 | try test_validate( 275 | .HS384, 276 | expected, 277 | "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.2B5ucfIDtuSVRisXjPwZlqPAwgEicFIX7Gd2r8rlAbLukenHTW0Rbx1ca1VJSyLg", 278 | "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow", 279 | ); 280 | try test_validate( 281 | .HS512, 282 | expected, 283 | "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.TrGchM_jCqCTAYUQlFmXt-KOyKO0O2wYYW5fUSV8jtdgqWJ74cqNA1zc9Ix7TU4qJ-Y32rKmP9Xpu99yiShx6g", 284 | "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow", 285 | ); 286 | } 287 | 288 | test "generate and then validate jws token" { 289 | try test_generate_then_validate(.HS256, .{ .key = "a jws hmac sha-256 test key" }); 290 | try test_generate_then_validate(.HS384, .{ .key = "a jws hmac sha-384 test key" }); 291 | } 292 | 293 | const TestPayload = struct { 294 | sub: []const u8, 295 | name: []const u8, 296 | iat: i64, 297 | }; 298 | 299 | fn test_generate(comptime algorithm: Algorithm, payload: TestPayload, expected: []const u8, key_base64: []const u8) !void { 300 | const key = try std.testing.allocator.alloc(u8, try base64url.Decoder.calcSizeForSlice(key_base64)); 301 | defer std.testing.allocator.free(key); 302 | try base64url.Decoder.decode(key, key_base64); 303 | 304 | const token = try encode(std.testing.allocator, algorithm, payload, .{ .key = key }); 305 | defer std.testing.allocator.free(token); 306 | 307 | try std.testing.expectEqualSlices(u8, expected, token); 308 | } 309 | 310 | const TestValidatePayload = struct { 311 | iss: []const u8, 312 | exp: i64, 313 | @"http://example.com/is_root": bool, 314 | }; 315 | 316 | fn test_validate(comptime algorithm: Algorithm, expected: TestValidatePayload, token: []const u8, key_base64: []const u8) !void { 317 | const key = try std.testing.allocator.alloc(u8, try base64url.Decoder.calcSizeForSlice(key_base64)); 318 | defer std.testing.allocator.free(key); 319 | try base64url.Decoder.decode(key, key_base64); 320 | 321 | var claims_p = try validate(TestValidatePayload, std.testing.allocator, algorithm, token, .{ .key = key }); 322 | defer claims_p.deinit(); 323 | const claims = claims_p.value; 324 | 325 | try std.testing.expectEqualSlices(u8, expected.iss, claims.iss); 326 | try std.testing.expectEqual(expected.exp, claims.exp); 327 | try std.testing.expectEqual(expected.@"http://example.com/is_root", claims.@"http://example.com/is_root"); 328 | } 329 | 330 | fn test_generate_then_validate(comptime alg: Algorithm, signatureOptions: SignatureOptions) !void { 331 | const Payload = struct { 332 | sub: []const u8, 333 | name: []const u8, 334 | iat: i64, 335 | }; 336 | const payload = Payload{ 337 | .sub = "1234567890", 338 | .name = "John Doe", 339 | .iat = 1516239022, 340 | }; 341 | 342 | const token = try encode(std.testing.allocator, alg, payload, signatureOptions); 343 | defer std.testing.allocator.free(token); 344 | 345 | var decoded_p = try validate(Payload, std.testing.allocator, alg, token, signatureOptions); 346 | defer decoded_p.deinit(); 347 | const decoded = decoded_p.value; 348 | 349 | try std.testing.expectEqualSlices(u8, payload.sub, decoded.sub); 350 | try std.testing.expectEqualSlices(u8, payload.name, decoded.name); 351 | try std.testing.expectEqual(payload.iat, decoded.iat); 352 | } 353 | -------------------------------------------------------------------------------- /zig.mod: -------------------------------------------------------------------------------- 1 | id: 2puqxsisuaodaiftbs1smvidrmlxbofeirmt29lfkh2ybyeo 2 | name: jwt 3 | main: jwt.zig 4 | license: MIT 5 | dependencies: 6 | --------------------------------------------------------------------------------