atproto pds in zig pds.zat.dev
pds atproto
24

Configure Feed

Select the types of activity you want to include in your feed.

Accept service auth for blob uploads

zzstoatzz (May 27, 2026, 3:29 PM -0500) 81eb9630 fe946715

+129 -6
+1 -1
src/atproto/proxy.zig
··· 80 80 log.err("xrpc proxy signing key missing method={s} account={s}\n", .{ method, account.did }); 81 81 return http_api.xrpcError(request, .bad_gateway, "UpstreamFailure", "Could not load account signing key"); 82 82 }; 83 - const service_token = try auth.createServiceJwtWithKeypair(allocator, account, service.did, method, &keypair); 83 + const service_token = try auth.createServiceJwtWithKeypair(allocator, account, service.did, method, null, &keypair); 84 84 const authorization = try std.fmt.allocPrint(allocator, "Bearer {s}", .{service_token}); 85 85 86 86 var transport = zat.HttpTransport.init(store.currentIo(), allocator);
+90 -2
src/atproto/repo.zig
··· 373 373 defer arena.deinit(); 374 374 const allocator = arena.allocator(); 375 375 376 - const auth_ctx = requireAccount(request, allocator) catch return; 376 + const auth_ctx = requireBlobUploadAuth(request, allocator) catch return; 377 377 const account = auth_ctx.account; 378 378 const mime_type = try allocator.dupe(u8, http_api.headerValue(request, "content-type") orelse "application/octet-stream"); 379 - try requireBlobScope(request, auth_ctx.oauth_scope, mime_type); 379 + if (auth_ctx.oauth_scope) |scope| { 380 + try requireBlobScope(request, scope, mime_type); 381 + } 380 382 const body = http_api.readBodyAlloc(request, allocator, config.blobUploadLimit()) catch |err| switch (err) { 381 383 error.BodyTooLarge => return http_api.xrpcError(request, .payload_too_large, "InvalidRequest", "blob is too large"), 382 384 else => return err, ··· 386 388 }; 387 389 const body_out = try uploadBlobResponseJson(allocator, cid, mime_type, body.len); 388 390 return http_api.json(request, .ok, body_out); 391 + } 392 + 393 + const BlobUploadAuth = struct { 394 + account: auth.Account, 395 + oauth_scope: ?[]const u8, 396 + }; 397 + 398 + fn requireBlobUploadAuth(request: *http.Server.Request, allocator: std.mem.Allocator) !BlobUploadAuth { 399 + if (http_api.requireBearerAccess(request, allocator)) |ctx| { 400 + return .{ .account = ctx.account, .oauth_scope = ctx.oauth_scope }; 401 + } else |err| switch (err) { 402 + error.AuthRequired => { 403 + try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"); 404 + return error.HandledResponse; 405 + }, 406 + error.InvalidToken => return requireBlobUploadServiceAuth(request, allocator), 407 + } 408 + } 409 + 410 + fn requireBlobUploadServiceAuth(request: *http.Server.Request, allocator: std.mem.Allocator) !BlobUploadAuth { 411 + const raw_header = http_api.headerValue(request, "authorization") orelse { 412 + try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"); 413 + return error.HandledResponse; 414 + }; 415 + if (!std.ascii.startsWithIgnoreCase(raw_header, "bearer ")) { 416 + try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"); 417 + return error.HandledResponse; 418 + } 419 + const token = std.mem.trim(u8, raw_header["bearer ".len..], " \t"); 420 + var jwt = zat.Jwt.parse(allocator, token) catch { 421 + try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 422 + return error.HandledResponse; 423 + }; 424 + defer jwt.deinit(); 425 + 426 + const issuer_did = issuerDid(jwt.payload.iss); 427 + if (zat.Did.parse(issuer_did) == null or !serviceAuthAudienceMatches(jwt.payload.aud)) { 428 + try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 429 + return error.HandledResponse; 430 + } 431 + if (jwt.isExpired(store.currentIo())) { 432 + try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 433 + return error.HandledResponse; 434 + } 435 + const lxm = jwt.payload.lxm orelse { 436 + try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 437 + return error.HandledResponse; 438 + }; 439 + if (!std.mem.eql(u8, lxm, "com.atproto.repo.uploadBlob") and !std.mem.eql(u8, lxm, "*")) { 440 + try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 441 + return error.HandledResponse; 442 + } 443 + 444 + const account = (store.findAccount(allocator, issuer_did) catch null) orelse { 445 + try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 446 + return error.HandledResponse; 447 + }; 448 + 449 + var resolver = zat.DidResolver.init(store.currentIo(), allocator); 450 + defer resolver.deinit(); 451 + var doc = resolver.resolve(zat.Did.parse(issuer_did).?) catch { 452 + try http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "could not resolve jwt issuer did"); 453 + return error.HandledResponse; 454 + }; 455 + defer doc.deinit(); 456 + const signing_key = doc.signingKey() orelse { 457 + try http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "missing signing key in issuer did doc"); 458 + return error.HandledResponse; 459 + }; 460 + jwt.verify(signing_key.public_key_multibase) catch { 461 + try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 462 + return error.HandledResponse; 463 + }; 464 + return .{ .account = account, .oauth_scope = null }; 465 + } 466 + 467 + fn serviceAuthAudienceMatches(aud: []const u8) bool { 468 + if (std.mem.eql(u8, aud, config.serverDid())) return true; 469 + if (!std.mem.startsWith(u8, aud, config.serverDid())) return false; 470 + const rest = aud[config.serverDid().len..]; 471 + return std.mem.eql(u8, rest, "#atproto_pds"); 472 + } 473 + 474 + fn issuerDid(issuer: []const u8) []const u8 { 475 + if (std.mem.indexOfScalar(u8, issuer, '#')) |hash| return issuer[0..hash]; 476 + return issuer; 389 477 } 390 478 391 479 fn uploadBlobResponseJson(
+35 -1
src/atproto/server.zig
··· 713 713 } 714 714 } 715 715 716 + const requested_exp = try requestedServiceAuthExpiration(request, lxm != null); 717 + 716 718 var keypair = try store.signingKeypair(account.did); 717 - const token = try auth.createServiceJwtWithKeypair(allocator, account, audience, lxm, &keypair); 719 + const token = try auth.createServiceJwtWithKeypair(allocator, account, audience, lxm, requested_exp, &keypair); 718 720 const body_out = try std.fmt.allocPrint(allocator, "{{\"token\":\"{s}\"}}", .{token}); 719 721 return http_api.json(request, .ok, body_out); 722 + } 723 + 724 + fn requestedServiceAuthExpiration(request: *http.Server.Request, has_lxm: bool) !?i64 { 725 + var exp_buf: [32]u8 = undefined; 726 + const raw = http_api.queryParam(request.head.target, "exp", &exp_buf) orelse return null; 727 + const exp = std.fmt.parseInt(i64, raw, 10) catch { 728 + try http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid exp"); 729 + return error.HandledResponse; 730 + }; 731 + const now = unixNow(); 732 + const diff = exp - now; 733 + if (diff < 0) { 734 + try http_api.xrpcError(request, .bad_request, "BadExpiration", "expiration is in past"); 735 + return error.HandledResponse; 736 + } 737 + if (diff > 60 * 60) { 738 + try http_api.xrpcError(request, .bad_request, "BadExpiration", "cannot request a token with an expiration more than an hour in the future"); 739 + return error.HandledResponse; 740 + } 741 + if (!has_lxm and diff > 60) { 742 + try http_api.xrpcError(request, .bad_request, "BadExpiration", "cannot request a method-less token with an expiration more than a minute in the future"); 743 + return error.HandledResponse; 744 + } 745 + return exp; 746 + } 747 + 748 + fn unixNow() i64 { 749 + var ts: std.posix.timespec = undefined; 750 + return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { 751 + .SUCCESS => ts.sec, 752 + else => 0, 753 + }; 720 754 } 721 755 722 756 fn serviceAuthProtectedMethod(method: []const u8) bool {
+3 -2
src/auth/tokens.zig
··· 88 88 lexicon_method: ?[]const u8, 89 89 ) ![]const u8 { 90 90 const keypair = try serviceKeypair(); 91 - return createServiceJwtWithKeypair(allocator, account, audience, lexicon_method, &keypair); 91 + return createServiceJwtWithKeypair(allocator, account, audience, lexicon_method, null, &keypair); 92 92 } 93 93 94 94 pub fn createServiceJwtWithKeypair( ··· 96 96 account: Account, 97 97 audience: []const u8, 98 98 lexicon_method: ?[]const u8, 99 + requested_exp: ?i64, 99 100 keypair: *const zat.Keypair, 100 101 ) ![]const u8 { 101 102 const header = try std.fmt.allocPrint( ··· 105 106 ); 106 107 defer allocator.free(header); 107 108 const iat = unixNow(); 108 - const exp = iat + 60; 109 + const exp = requested_exp orelse iat + 60; 109 110 const jti = @atomicRmw(usize, &jwt_counter, .Add, 1, .monotonic); 110 111 111 112 const payload = if (lexicon_method) |lxm|