bsky feeds about music music-atmosphere-feed.plyr.fm/
bsky feed zig

add structured auth with DID doc caching and lxm validation

- AuthResult union type distinguishes verified/unverified/anonymous/rejected
- check lxm claim matches app.bsky.feed.getFeedSkeleton
- cache DID signing keys for 1 hour with invalidation on failure
- feeds can declare requiresAuth() and allowsUnverifiedFallback()
- add docs/feed-generator-auth.md with notes from bsky thread

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+360 -15
+1 -1
build.zig.zon
··· 14 14 }, 15 15 .zat = .{ 16 16 .url = "https://tangled.sh/zat.dev/zat/archive/main", 17 - .hash = "zat-0.1.0-5PuC7heIAQA4j2UVmJT-oivQh5AwZTrFQ-NC4CJi2-_R", 17 + .hash = "zat-0.1.0-5PuC7tmRAQCPov0UFkeXFBgfytd6_P3GZPSlvy9OvvgW", 18 18 }, 19 19 }, 20 20 .paths = .{
+112
docs/feed-generator-auth.md
··· 1 + # feed generator authentication 2 + 3 + notes from [this thread](https://bsky.app/profile/danabra.mov/post/3mddtekbtz224) (2026-01-26) 4 + 5 + ## jwt structure 6 + 7 + feed generators receive JWTs with these claims: 8 + - `iss` - issuer: the requesting user's DID 9 + - `aud` - audience: the feed generator's DID (e.g., `did:web:linklonk.com`) 10 + - `lxm` - the lexicon method being called (e.g., `app.bsky.feed.getFeedSkeleton`) 11 + 12 + ## verification flow 13 + 14 + 1. get JWT from `Authorization` header 15 + 2. extract `iss` (user's DID) from claims 16 + 3. fetch user's DID document 17 + 4. extract the `#atproto` verification method (public key) 18 + 5. validate JWT signature with that key 19 + 20 + > "a lot of people do not actually _verify_ the jwts, but you should by fetching the user's key from their did document" - @hailey.at 21 + 22 + ## why aud check matters 23 + 24 + without aud check: 25 + - malicious feed (Bad) gets a user's token 26 + - Bad forwards token to personalized feed (Good) 27 + - Bad discovers what Good would serve that user 28 + 29 + with aud check: 30 + - tokens are scoped to specific feed's DID 31 + - Bad can't reuse tokens meant for Good 32 + - Bad would need user to OAuth into Bad directly 33 + 34 + > confirmed by @futur.blue: "aud is the feedgen, iss is requester" 35 + 36 + ## current architecture (confusing) 37 + 38 + the current flow: `user -> PDS -> appview -> feed generator` 39 + 40 + PDS proxies requests to appview, which then calls the feed generator. this is "very unintuitive" and makes it hard to understand: 41 + - who signs what 42 + - what audience to expect 43 + - how bff-style appviews could consume personalized feeds 44 + 45 + open question: when PDS proxying goes away, does appview call PDS to sign temporary JWTs addressed to the feed service? 46 + 47 + ## getting service auth tokens 48 + 49 + for testing/development: 50 + - `com.atproto.server.getServiceAuth` endpoint generates serviceAuth tokens 51 + - lexicon.garden can test this interactively: 52 + 1. visit lexicon.garden/nsid/app.bsky.feed.getFeedSkeleton and login 53 + 2. set service endpoint (e.g., `labs.graze.social`) 54 + 3. set aud to feed's DID (e.g., `did:web:labs.graze.social`) 55 + 4. inspect network request with browser dev tools 56 + 5. decode JWT at jwt.io to see claims 57 + 58 + for backend apps with OAuth sessions: 59 + - ask PDS for a service JWT via XRPC 60 + - request feed directly with that token 61 + 62 + ## performance tip 63 + 64 + > "cache the did document; if you get a token validation error - remove the cached document and try again" - @spacecowboy17.bsky.social 65 + 66 + ## reference implementations 67 + 68 + | language | repo | notes | 69 + |----------|------|-------| 70 + | go | [haileyok/peruse](https://github.com/haileyok/peruse) | full flow with JWT verification | 71 + | python | [MarshalX/bluesky-feed-generator](https://github.com/MarshalX/bluesky-feed-generator) | SDK example, see auth.py | 72 + | rust | baileytownsend's PDS MOOver | auth endpoints and checks | 73 + 74 + ## our current state 75 + 76 + auth implemented in `src/bsky/auth.zig`: 77 + 78 + - [x] **aud check** - validates `jwt.payload.aud == service_did` 79 + - [x] **lxm check** - validates `jwt.payload.lxm == "app.bsky.feed.getFeedSkeleton"` 80 + - [x] **signature verification** - resolves DID doc and verifies signature 81 + - [x] **expiration check** - `jwt.isExpired()` 82 + - [x] **DID document caching** - `src/store/did_cache.zig` with 1 hour TTL, invalidation on verification failure 83 + 84 + ### AuthResult type 85 + 86 + the new `auth.authenticate()` returns a union type that distinguishes auth states: 87 + 88 + ```zig 89 + pub const AuthResult = union(enum) { 90 + anonymous, // no header 91 + verified: []const u8, // signature verified 92 + unverified: []const u8, // claims parsed but verification failed 93 + rejected: AuthError, // invalid/expired/bad aud/bad lxm 94 + }; 95 + ``` 96 + 97 + ### feed-level auth control 98 + 99 + feeds can declare their auth requirements via `FeedType` methods: 100 + 101 + - `requiresAuth()` - whether the feed requires authentication (returns 401 if not) 102 + - `allowsUnverifiedFallback()` - whether unverified claims are acceptable 103 + 104 + current settings: 105 + - `following` - requires auth (needs user identity), allows unverified fallback 106 + - `all`, `organic` - no auth required, allows unverified fallback 107 + 108 + ## future work 109 + 110 + 1. **stricter auth for sensitive feeds** - set `allowsUnverifiedFallback() = false` for feeds where identity matters for access control (not just filtering) 111 + 2. **rate limiting by auth state** - could rate limit unverified requests more aggressively 112 + 3. **metrics** - track verified vs unverified vs rejected auth to understand real-world behavior
+120
src/bsky/auth.zig
··· 2 2 const mem = std.mem; 3 3 const Allocator = mem.Allocator; 4 4 const zat = @import("zat"); 5 + const did_cache = @import("../store/did_cache.zig"); 5 6 6 7 const log = std.log.scoped(.bsky_auth); 7 8 9 + /// authentication result - distinguishes between auth states 10 + pub const AuthResult = union(enum) { 11 + /// no authorization header provided 12 + anonymous, 13 + /// JWT signature verified against DID document 14 + verified: []const u8, 15 + /// claims parsed but verification failed (use with caution) 16 + unverified: []const u8, 17 + /// authentication rejected 18 + rejected: AuthError, 19 + 20 + pub fn getDid(self: AuthResult) ?[]const u8 { 21 + return switch (self) { 22 + .verified => |did| did, 23 + .unverified => |did| did, 24 + .anonymous, .rejected => null, 25 + }; 26 + } 27 + 28 + pub fn isVerified(self: AuthResult) bool { 29 + return self == .verified; 30 + } 31 + }; 32 + 33 + pub const AuthError = enum { 34 + invalid_token, 35 + expired, 36 + bad_audience, 37 + bad_lxm, 38 + resolution_failed, 39 + signature_invalid, 40 + }; 41 + 42 + /// authenticate a request, returning detailed result 43 + pub fn authenticate( 44 + alloc: Allocator, 45 + auth_header: ?[]const u8, 46 + service_did: []const u8, 47 + expected_lxm: ?[]const u8, 48 + ) AuthResult { 49 + const auth = auth_header orelse return .anonymous; 50 + if (!mem.startsWith(u8, auth, "Bearer ")) return .{ .rejected = .invalid_token }; 51 + 52 + const token = auth["Bearer ".len..]; 53 + 54 + var jwt = zat.Jwt.parse(alloc, token) catch { 55 + log.debug("jwt parse failed", .{}); 56 + return .{ .rejected = .invalid_token }; 57 + }; 58 + defer jwt.deinit(); 59 + 60 + if (jwt.isExpired()) { 61 + log.debug("jwt expired", .{}); 62 + return .{ .rejected = .expired }; 63 + } 64 + 65 + if (!mem.eql(u8, jwt.payload.aud, service_did)) { 66 + log.debug("jwt audience mismatch: got {s}, expected {s}", .{ jwt.payload.aud, service_did }); 67 + return .{ .rejected = .bad_audience }; 68 + } 69 + 70 + // check lxm claim if expected 71 + if (expected_lxm) |lxm| { 72 + const jwt_lxm = jwt.payload.lxm orelse { 73 + log.debug("jwt missing lxm claim", .{}); 74 + return .{ .rejected = .bad_lxm }; 75 + }; 76 + if (!mem.eql(u8, jwt_lxm, lxm)) { 77 + log.debug("jwt lxm mismatch: got {s}, expected {s}", .{ jwt_lxm, lxm }); 78 + return .{ .rejected = .bad_lxm }; 79 + } 80 + } 81 + 82 + // try to verify signature 83 + const issuer_did = jwt.payload.iss; 84 + 85 + // check cache first 86 + if (did_cache.getSigningKey(alloc, issuer_did)) |cached_key| { 87 + defer alloc.free(cached_key); 88 + if (jwt.verify(cached_key)) { 89 + return .{ .verified = alloc.dupe(u8, issuer_did) catch return .{ .rejected = .resolution_failed } }; 90 + } else |_| { 91 + // cached key failed, invalidate and try fresh resolution 92 + did_cache.invalidate(issuer_did); 93 + } 94 + } 95 + 96 + // resolve DID document 97 + const did = zat.Did.parse(issuer_did) orelse { 98 + log.debug("invalid issuer DID: {s}", .{issuer_did}); 99 + return .{ .unverified = alloc.dupe(u8, issuer_did) catch return .{ .rejected = .resolution_failed } }; 100 + }; 101 + 102 + var resolver = zat.DidResolver.init(alloc); 103 + defer resolver.deinit(); 104 + 105 + var doc = resolver.resolve(did) catch |err| { 106 + log.debug("DID resolution failed: {}", .{err}); 107 + return .{ .unverified = alloc.dupe(u8, issuer_did) catch return .{ .rejected = .resolution_failed } }; 108 + }; 109 + defer doc.deinit(); 110 + 111 + const signing_key = doc.signingKey() orelse { 112 + log.debug("no signing key in DID document", .{}); 113 + return .{ .unverified = alloc.dupe(u8, issuer_did) catch return .{ .rejected = .resolution_failed } }; 114 + }; 115 + 116 + // cache the signing key for future requests 117 + did_cache.cache(issuer_did, signing_key.public_key_multibase); 118 + 119 + jwt.verify(signing_key.public_key_multibase) catch |err| { 120 + log.debug("jwt signature verification failed: {}", .{err}); 121 + return .{ .unverified = alloc.dupe(u8, issuer_did) catch return .{ .rejected = .signature_invalid } }; 122 + }; 123 + 124 + return .{ .verified = alloc.dupe(u8, issuer_did) catch return .{ .rejected = .resolution_failed } }; 125 + } 126 + 8 127 /// extract requester DID from Authorization header (claims-only, no signature verification) 128 + /// DEPRECATED: use authenticate() for new code 9 129 pub fn getRequesterDid(alloc: Allocator, auth_header: ?[]const u8, service_did: []const u8) ?[]const u8 { 10 130 const auth = auth_header orelse return null; 11 131 if (!mem.startsWith(u8, auth, "Bearer ")) return null;
+17
src/feed/config.zig
··· 14 14 all, // music-atmosphere: all music posts 15 15 following, // music-following: only from people you follow 16 16 organic, // music-organic: excludes high-volume bot posters 17 + 18 + /// whether this feed requires verified authentication 19 + pub fn requiresAuth(self: FeedType) bool { 20 + return switch (self) { 21 + .following => true, // needs user identity to filter by follows 22 + .all, .organic => false, 23 + }; 24 + } 25 + 26 + /// whether this feed can use unverified claims as fallback 27 + /// (less secure, but avoids breaking on transient DID resolution failures) 28 + pub fn allowsUnverifiedFallback(self: FeedType) bool { 29 + return switch (self) { 30 + .following => true, // identity used for filtering, not access control 31 + .all, .organic => true, 32 + }; 33 + } 17 34 }; 18 35 19 36 var service_did_buf: [256]u8 = undefined;
+35 -14
src/server/http.zig
··· 120 120 defer arena.deinit(); 121 121 const alloc = arena.allocator(); 122 122 123 - // extract and verify requester DID from authorization header 124 - const auth_header = getHeader(request, "authorization"); 125 - const service_did = config.getServiceDid(); 126 - 127 - // use verified version for full signature verification 128 - // falls back to claims-only if DID resolution fails 129 - var requester_did = auth.getRequesterDidVerified(alloc, auth_header, service_did); 130 - if (requester_did == null) { 131 - // fallback to claims-only verification 132 - requester_did = auth.getRequesterDid(alloc, auth_header, service_did); 133 - } 134 - 135 - // parse query params 123 + // parse query params first to know which feed 136 124 const feed_param = parseQueryParam(alloc, target, "feed") catch { 137 125 try sendJson(request, .bad_request, 138 126 \\{"error":"feed parameter missing"} ··· 148 136 return; 149 137 }; 150 138 139 + // authenticate request 140 + const auth_header = getHeader(request, "authorization"); 141 + const service_did = config.getServiceDid(); 142 + const auth_result = auth.authenticate(alloc, auth_header, service_did, "app.bsky.feed.getFeedSkeleton"); 143 + 144 + // determine requester DID based on auth result and feed requirements 145 + const requester_did: ?[]const u8 = switch (auth_result) { 146 + .verified => |did| did, 147 + .unverified => |did| blk: { 148 + if (feed_type.allowsUnverifiedFallback()) { 149 + std.debug.print("warning: using unverified auth for {s}\n", .{@tagName(feed_type)}); 150 + break :blk did; 151 + } 152 + std.debug.print("rejecting unverified auth for {s}\n", .{@tagName(feed_type)}); 153 + break :blk null; 154 + }, 155 + .anonymous => null, 156 + .rejected => |err| blk: { 157 + std.debug.print("auth rejected: {s}\n", .{@tagName(err)}); 158 + break :blk null; 159 + }, 160 + }; 161 + 162 + // check if feed requires authentication 163 + if (feed_type.requiresAuth() and requester_did == null) { 164 + try sendJson(request, .unauthorized, 165 + \\{"error":"authentication required"} 166 + ); 167 + return; 168 + } 169 + 170 + // log request 151 171 if (requester_did) |did| { 152 - std.debug.print("feed request: {s} from: {s}\n", .{ @tagName(feed_type), did }); 172 + const verified_str = if (auth_result.isVerified()) " (verified)" else " (unverified)"; 173 + std.debug.print("feed request: {s} from: {s}{s}\n", .{ @tagName(feed_type), did, verified_str }); 153 174 } else { 154 175 std.debug.print("feed request: {s} (anonymous)\n", .{@tagName(feed_type)}); 155 176 }
+11
src/store/db.zig
··· 83 83 std.debug.print("failed to create backfilled_accounts table: {}\n", .{err}); 84 84 return err; 85 85 }; 86 + 87 + c.exec( 88 + \\CREATE TABLE IF NOT EXISTS did_doc_cache ( 89 + \\ did TEXT PRIMARY KEY, 90 + \\ signing_key TEXT NOT NULL, 91 + \\ cached_at INTEGER NOT NULL 92 + \\) 93 + , .{}) catch |err| { 94 + std.debug.print("failed to create did_doc_cache table: {}\n", .{err}); 95 + return err; 96 + }; 86 97 } 87 98 88 99 pub fn getConn() ?zqlite.Conn {
+64
src/store/did_cache.zig
··· 1 + const std = @import("std"); 2 + const db = @import("db.zig"); 3 + 4 + const log = std.log.scoped(.did_cache); 5 + 6 + /// cache a DID's signing key 7 + pub fn cache(did: []const u8, signing_key: []const u8) void { 8 + db.lock(); 9 + defer db.unlock(); 10 + 11 + const conn = db.getConn() orelse return; 12 + const now = std.time.milliTimestamp(); 13 + 14 + conn.exec( 15 + "INSERT OR REPLACE INTO did_doc_cache (did, signing_key, cached_at) VALUES (?, ?, ?)", 16 + .{ did, signing_key, now }, 17 + ) catch |err| { 18 + log.debug("failed to cache DID signing key: {}", .{err}); 19 + }; 20 + } 21 + 22 + /// get cached signing key for a DID (caller owns memory) 23 + pub fn getSigningKey(alloc: std.mem.Allocator, did: []const u8) ?[]const u8 { 24 + db.lock(); 25 + defer db.unlock(); 26 + 27 + const conn = db.getConn() orelse return null; 28 + 29 + // check if cache is fresh (1 hour TTL) 30 + const now = std.time.milliTimestamp(); 31 + const max_age_ms = 60 * 60 * 1000; // 1 hour 32 + 33 + var rows = conn.rows( 34 + "SELECT signing_key FROM did_doc_cache WHERE did = ? AND cached_at > ?", 35 + .{ did, now - max_age_ms }, 36 + ) catch return null; 37 + defer rows.deinit(); 38 + 39 + if (rows.next()) |row| { 40 + return alloc.dupe(u8, row.text(0)) catch null; 41 + } 42 + return null; 43 + } 44 + 45 + /// invalidate cached DID document (e.g., on verification failure) 46 + pub fn invalidate(did: []const u8) void { 47 + db.lock(); 48 + defer db.unlock(); 49 + 50 + const conn = db.getConn() orelse return; 51 + conn.exec("DELETE FROM did_doc_cache WHERE did = ?", .{did}) catch {}; 52 + } 53 + 54 + /// cleanup expired entries (call periodically) 55 + pub fn cleanup() void { 56 + db.lock(); 57 + defer db.unlock(); 58 + 59 + const conn = db.getConn() orelse return; 60 + const now = std.time.milliTimestamp(); 61 + const max_age_ms = 60 * 60 * 1000; // 1 hour 62 + 63 + conn.exec("DELETE FROM did_doc_cache WHERE cached_at < ?", .{now - max_age_ms}) catch {}; 64 + }