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

Configure Feed

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

Support query-form OAuth repo scopes

zzstoatzz (May 24, 2026, 6:35 PM -0500) 785c3c6b 4268ad45

+39 -9
+38 -9
src/atproto/oauth.zig
··· 405 405 const iss = zat.json.getString(payload.value, "iss") orelse return error.InvalidJwt; 406 406 const sub = zat.json.getString(payload.value, "sub") orelse return error.InvalidJwt; 407 407 const aud = zat.json.getString(payload.value, "aud") orelse return error.InvalidJwt; 408 - const exp = zat.json.getInt(payload.value, "exp") orelse return error.InvalidJwt; 408 + const exp = zat.json.getInt(payload.value, "exp"); 409 + const iat = zat.json.getInt(payload.value, "iat"); 409 410 _ = zat.json.getString(payload.value, "jti") orelse return error.InvalidJwt; 410 411 if (!std.mem.eql(u8, iss, client_id) or !std.mem.eql(u8, sub, client_id)) return error.InvalidJwt; 411 412 if (!std.mem.eql(u8, aud, config.publicUrl())) return error.InvalidJwt; 412 - if (exp < now()) return error.InvalidJwt; 413 + if (!clientAssertionTimestampIsFresh(now(), exp, iat)) return error.InvalidJwt; 413 414 414 415 const jwks_uri = zat.json.getString(metadata, "jwks_uri") orelse return error.InvalidClientMetadata; 415 416 const jwks_doc = try fetchJson(allocator, jwks_uri, 256 * 1024); ··· 676 677 has_transition = true; 677 678 continue; 678 679 } 679 - if (std.mem.startsWith(u8, scope, "repo:") or 680 - std.mem.startsWith(u8, scope, "blob:") or 681 - std.mem.startsWith(u8, scope, "rpc:") or 682 - std.mem.startsWith(u8, scope, "account:") or 683 - std.mem.startsWith(u8, scope, "identity:") or 684 - std.mem.startsWith(u8, scope, "include:")) 685 - { 680 + if (isGranularScope(scope)) { 686 681 has_granular = true; 687 682 continue; 688 683 } ··· 692 687 if (has_transition and has_granular) { 693 688 return oauthError(request, .bad_request, "invalid_scope", "Cannot mix transition scopes with granular scopes"); 694 689 } 690 + } 691 + 692 + fn clientAssertionTimestampIsFresh(current_time: i64, exp: ?i64, iat: ?i64) bool { 693 + if (exp) |expires_at| return expires_at >= current_time; 694 + const issued_at = iat orelse return false; 695 + if (issued_at > current_time + 60) return false; 696 + return current_time - issued_at <= 300; 697 + } 698 + 699 + fn isGranularScope(scope: []const u8) bool { 700 + return std.mem.startsWith(u8, scope, "repo:") or 701 + std.mem.startsWith(u8, scope, "repo?") or 702 + std.mem.startsWith(u8, scope, "blob:") or 703 + std.mem.startsWith(u8, scope, "rpc:") or 704 + std.mem.startsWith(u8, scope, "account:") or 705 + std.mem.startsWith(u8, scope, "identity:") or 706 + std.mem.startsWith(u8, scope, "include:"); 695 707 } 696 708 697 709 fn percentEncode(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { ··· 744 756 }, 745 757 }); 746 758 } 759 + 760 + test "client assertion accepts exp or recent iat" { 761 + try std.testing.expect(clientAssertionTimestampIsFresh(1000, 1060, null)); 762 + try std.testing.expect(clientAssertionTimestampIsFresh(1000, null, 940)); 763 + try std.testing.expect(!clientAssertionTimestampIsFresh(1000, 999, null)); 764 + try std.testing.expect(!clientAssertionTimestampIsFresh(1000, null, 699)); 765 + try std.testing.expect(!clientAssertionTimestampIsFresh(1000, null, 1061)); 766 + try std.testing.expect(!clientAssertionTimestampIsFresh(1000, null, null)); 767 + } 768 + 769 + test "granular scopes accept query-form repo permissions" { 770 + try std.testing.expect(isGranularScope("repo:*")); 771 + try std.testing.expect(isGranularScope("repo?collection=place.stream.live&action=create")); 772 + try std.testing.expect(isGranularScope("repo?action=create&collection=place.stream.live")); 773 + try std.testing.expect(isGranularScope("rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview")); 774 + try std.testing.expect(!isGranularScope("transition:generic")); 775 + }
+1
tools/smoke.sh
··· 45 45 done 46 46 47 47 curl -fsS "$base/xrpc/_health" >/dev/null 48 + curl -fsS "$base/xrpc/com.atproto.server.describeServer" >/dev/null 48 49 sqlite3 "$db" "insert into accounts (did, handle, email, password_hash, activated_at, email_confirmed_at) values ('did:plc:smoketest', 'smoke.test', 'smoke@test.com', 'password', unixepoch(), unixepoch())" 49 50 sqlite3 "$db" "insert into oauth_requests (request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, login_hint, expires_at) values ('smoke-oauth', 'https://client.example/oauth-client.json', 'https://client.example/callback', 'repo:*?action=create blob:*/*', 'state', 'challenge', 'S256', 'smoke.test', unixepoch() + 600)" 50 51