Pinned 2026 toolchains (Go 1.26, Rust 1.98/edition 2024, Python 3.14 + uv, Node 24, Zig 0.16, NixOS 26.05), postgres 18 / mongo 8, lockfiles built from, non-root runtimes, .dockerignore, per-project LICENSE, READMEs with the git.devai.io clone line, checkout@v7 CI. Security fixes in the legacy Rust APIs (any-password login, self-assigned admin, hard-coded JWT secret), JWT alg/exp/sub enforcement across the blog series, safe markdown links in the frontends, and many smaller bugs — every project was built, run and exercised end to end. Adds scripts/publish.sh + a CI publish job that splits every folder into its own repo at git.devai.io/templates/<folder>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01128fhuZbgivaSJvtMf4s1G
146 lines
6 KiB
Zig
146 lines
6 KiB
Zig
//! Drop-in Clerk session-token verifier: checks RS256 JWTs against your
|
|
//! Clerk instance's JWKS using only std. See the README next to this file
|
|
//! for how to wire it in.
|
|
|
|
const std = @import("std");
|
|
const Allocator = std.mem.Allocator;
|
|
const rsa = std.crypto.Certificate.rsa;
|
|
const Sha256 = std.crypto.hash.sha2.Sha256;
|
|
const b64 = std.base64.url_safe_no_pad;
|
|
|
|
/// Tolerated clock skew between Clerk and this server, in seconds.
|
|
const leeway = 5;
|
|
|
|
pub const Verifier = struct {
|
|
gpa: Allocator,
|
|
io: std.Io,
|
|
/// e.g. https://your-instance.clerk.accounts.dev/.well-known/jwks.json
|
|
jwks_url: []const u8,
|
|
mutex: std.Io.Mutex = .init,
|
|
keys_arena: std.heap.ArenaAllocator,
|
|
keys: []const Jwk = &.{},
|
|
fetched_at: i64 = 0,
|
|
|
|
const Jwk = struct { kid: []const u8, n: []const u8, e: []const u8 };
|
|
|
|
pub fn init(gpa: Allocator, io: std.Io, jwks_url: []const u8) Verifier {
|
|
return .{ .gpa = gpa, .io = io, .jwks_url = jwks_url, .keys_arena = .init(gpa) };
|
|
}
|
|
|
|
pub fn deinit(self: *Verifier) void {
|
|
self.keys_arena.deinit();
|
|
}
|
|
|
|
/// Checks the signature, `exp` and `nbf`; returns the Clerk user id
|
|
/// (`sub`, e.g. "user_2f8a...") allocated in `arena`. Thread-safe.
|
|
pub fn verify(self: *Verifier, arena: Allocator, token: []const u8) ![]const u8 {
|
|
var parts = std.mem.splitScalar(u8, token, '.');
|
|
const header_b64 = parts.next() orelse return error.InvalidToken;
|
|
const payload_b64 = parts.next() orelse return error.InvalidToken;
|
|
const sig_b64 = parts.next() orelse return error.InvalidToken;
|
|
if (parts.next() != null) return error.InvalidToken;
|
|
|
|
const header = try parseSegment(struct { alg: []const u8, kid: []const u8 }, arena, header_b64);
|
|
if (!std.mem.eql(u8, header.alg, "RS256")) return error.InvalidToken;
|
|
|
|
const jwk = (try self.findKey(arena, header.kid)) orelse blk: {
|
|
// Unknown kid: Clerk may have rotated its keys.
|
|
try self.refresh();
|
|
break :blk (try self.findKey(arena, header.kid)) orelse return error.UnknownKey;
|
|
};
|
|
try verifyRs256(arena, jwk, token[0 .. header_b64.len + 1 + payload_b64.len], sig_b64);
|
|
|
|
const claims = try parseSegment(struct {
|
|
sub: []const u8,
|
|
exp: i64,
|
|
nbf: i64 = 0,
|
|
}, arena, payload_b64);
|
|
const now = std.Io.Clock.now(.real, self.io).toSeconds();
|
|
if (claims.exp + leeway <= now or claims.nbf - leeway > now) return error.TokenExpired;
|
|
return claims.sub;
|
|
}
|
|
|
|
fn findKey(self: *Verifier, arena: Allocator, kid: []const u8) !?Jwk {
|
|
self.mutex.lockUncancelable(self.io);
|
|
defer self.mutex.unlock(self.io);
|
|
for (self.keys) |key| {
|
|
if (!std.mem.eql(u8, key.kid, kid)) continue;
|
|
// Copy out so a concurrent refresh can't free it under us.
|
|
return .{
|
|
.kid = try arena.dupe(u8, key.kid),
|
|
.n = try arena.dupe(u8, key.n),
|
|
.e = try arena.dupe(u8, key.e),
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Refetches the JWKS, at most once a minute so tokens with made-up kids
|
|
/// can't turn every request into a call to Clerk.
|
|
fn refresh(self: *Verifier) !void {
|
|
const now = std.Io.Clock.now(.real, self.io).toSeconds();
|
|
{
|
|
self.mutex.lockUncancelable(self.io);
|
|
defer self.mutex.unlock(self.io);
|
|
if (now - self.fetched_at < 60) return;
|
|
self.fetched_at = now;
|
|
}
|
|
|
|
var body: std.Io.Writer.Allocating = .init(self.gpa);
|
|
defer body.deinit();
|
|
var client: std.http.Client = .{ .allocator = self.gpa, .io = self.io };
|
|
defer client.deinit();
|
|
const result = client.fetch(.{
|
|
.location = .{ .url = self.jwks_url },
|
|
.response_writer = &body.writer,
|
|
}) catch return error.JwksFetchFailed;
|
|
if (result.status != .ok) return error.JwksFetchFailed;
|
|
|
|
var arena: std.heap.ArenaAllocator = .init(self.gpa);
|
|
errdefer arena.deinit();
|
|
const jwks = std.json.parseFromSliceLeaky(struct {
|
|
keys: []const struct { kty: []const u8 = "", kid: []const u8 = "", n: []const u8 = "", e: []const u8 = "" },
|
|
}, arena.allocator(), body.written(), .{ .ignore_unknown_fields = true }) catch
|
|
return error.JwksInvalid;
|
|
var keys: std.ArrayList(Jwk) = .empty;
|
|
for (jwks.keys) |key| {
|
|
if (!std.mem.eql(u8, key.kty, "RSA") or key.kid.len == 0) continue;
|
|
try keys.append(arena.allocator(), .{ .kid = key.kid, .n = key.n, .e = key.e });
|
|
}
|
|
|
|
self.mutex.lockUncancelable(self.io);
|
|
defer self.mutex.unlock(self.io);
|
|
self.keys_arena.deinit();
|
|
self.keys_arena = arena;
|
|
self.keys = keys.items;
|
|
}
|
|
};
|
|
|
|
fn verifyRs256(arena: Allocator, jwk: Verifier.Jwk, signing_input: []const u8, sig_b64: []const u8) !void {
|
|
var modulus = try decode(arena, jwk.n);
|
|
while (modulus.len > 0 and modulus[0] == 0) modulus = modulus[1..];
|
|
const exponent = try decode(arena, jwk.e);
|
|
const sig = try decode(arena, sig_b64);
|
|
if (sig.len != modulus.len) return error.InvalidSignature;
|
|
|
|
const key = rsa.PublicKey.fromBytes(exponent, modulus) catch return error.InvalidKey;
|
|
switch (modulus.len) {
|
|
inline 256, 384, 512 => |len| rsa.PKCS1v1_5Signature.verify(len, sig[0..len].*, signing_input, key, Sha256) catch
|
|
return error.InvalidSignature,
|
|
else => return error.UnsupportedKeySize,
|
|
}
|
|
}
|
|
|
|
/// Decodes one base64url token segment and parses its JSON into `T`.
|
|
fn parseSegment(comptime T: type, arena: Allocator, segment: []const u8) !T {
|
|
return std.json.parseFromSliceLeaky(T, arena, try decode(arena, segment), .{
|
|
.ignore_unknown_fields = true,
|
|
}) catch error.InvalidToken;
|
|
}
|
|
|
|
fn decode(arena: Allocator, s: []const u8) ![]u8 {
|
|
const len = b64.Decoder.calcSizeForSlice(s) catch return error.InvalidToken;
|
|
const out = try arena.alloc(u8, len);
|
|
b64.Decoder.decode(out, s) catch return error.InvalidToken;
|
|
return out;
|
|
}
|