perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
13
fork

Configure Feed

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

Enforce session-backed access tokens

alice a876cddb 9279c98e

+92 -80
+9 -17
lib/ATProto/PDS/API/Misc.pm
··· 11 11 12 12 use ATProto::PDS::API::Helpers qw(find_account require_admin subject_key); 13 13 use ATProto::PDS::API::Server qw(require_auth); 14 - use ATProto::PDS::API::Util qw(iso8601 xrpc_error); 14 + use ATProto::PDS::API::Util qw(flatten_params iso8601 xrpc_error); 15 15 use ATProto::PDS::Auth::Password qw(hash_password random_hex); 16 16 use ATProto::PDS::EventStream qw(encode_error_frame encode_info_frame encode_message_frame); 17 17 use ATProto::PDS::Identity qw(account_did_doc normalize_handle service_did service_did_doc); ··· 24 24 25 25 sub register_misc_handlers ($registry, $app) { 26 26 $registry->register('com.atproto.identity.getRecommendedDidCredentials', sub ($c, $endpoint) { 27 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 27 + my (undef, $account) = require_auth($c, audience => 'access'); 28 28 return recommended_did_credentials($c->app->settings, $account); 29 29 }); 30 30 ··· 57 57 }); 58 58 59 59 $registry->register('com.atproto.identity.requestPlcOperationSignature', sub ($c, $endpoint) { 60 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 60 + my (undef, $account) = require_auth($c, audience => 'access'); 61 61 xrpc_error(400, 'InvalidRequest', 'account does not have an email address') 62 62 unless defined($account->{email}) && length($account->{email}); 63 63 my $token = $c->store->create_action_token( ··· 76 76 }); 77 77 78 78 $registry->register('com.atproto.identity.signPlcOperation', sub ($c, $endpoint) { 79 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 79 + my (undef, $account) = require_auth($c, audience => 'access'); 80 80 xrpc_error(400, 'InvalidRequest', 'PLC operations are only supported for did:plc accounts') 81 81 unless is_plc_did($account->{did}); 82 82 my $body = $c->req->json || {}; ··· 106 106 }); 107 107 108 108 $registry->register('com.atproto.identity.submitPlcOperation', sub ($c, $endpoint) { 109 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 109 + my (undef, $account) = require_auth($c, audience => 'access'); 110 110 xrpc_error(400, 'InvalidRequest', 'PLC operations are only supported for did:plc accounts') 111 111 unless is_plc_did($account->{did}); 112 112 my $body = $c->req->json || {}; ··· 140 140 }); 141 141 142 142 $registry->register('com.atproto.identity.updateHandle', sub ($c, $endpoint) { 143 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 143 + my (undef, $account) = require_auth($c, audience => 'access'); 144 144 my $body = $c->req->json || {}; 145 145 my $domain = $c->config_value('service_handle_domain', 'localhost'); 146 146 my $handle = normalize_handle($body->{handle}, $domain); ··· 184 184 }); 185 185 186 186 $registry->register('com.atproto.moderation.createReport', sub ($c, $endpoint) { 187 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 187 + my (undef, $account) = require_auth($c, audience => 'access'); 188 188 my $body = $c->req->json || {}; 189 189 assert_report_allowed($c, $account, $body->{reasonType}); 190 190 my $row = $c->store->create_report( ··· 205 205 }); 206 206 207 207 $registry->register('com.atproto.label.queryLabels', sub ($c, $endpoint) { 208 - my $patterns = [ _flatten_params($c->every_param('uriPatterns')) ]; 209 - my @sources = _flatten_params($c->every_param('sources')); 208 + my $patterns = [ flatten_params($c->every_param('uriPatterns')) ]; 209 + my @sources = flatten_params($c->every_param('sources')); 210 210 xrpc_error(400, 'InvalidRequest', 'uriPatterns is required') unless @$patterns; 211 211 my $page = $c->store->list_labels( 212 212 uri_patterns => $patterns, ··· 329 329 }); 330 330 return {}; 331 331 }); 332 - } 333 - 334 - sub _flatten_params (@values) { 335 - my @flat; 336 - for my $value (@values) { 337 - push @flat, ref($value) eq 'ARRAY' ? @$value : $value; 338 - } 339 - return @flat; 340 332 } 341 333 342 334 sub _label_view ($row) {
+19 -45
lib/ATProto/PDS/API/Repo.pm
··· 11 11 use JSON::PP (); 12 12 13 13 use ATProto::PDS::API::Server qw(require_auth); 14 - use ATProto::PDS::API::Util qw(blob_ref); 14 + use ATProto::PDS::API::Util qw(blob_ref resolve_repo xrpc_error); 15 15 use ATProto::PDS::Moderation qw(assert_record_readable assert_repo_readable assert_repo_writable is_record_takedown parse_at_uri); 16 16 use ATProto::PDS::Repo::CID; 17 17 ··· 19 19 20 20 sub register_repo_handlers ($registry, $app) { 21 21 $registry->register('com.atproto.repo.describeRepo', sub ($c, $endpoint) { 22 - my $account = _resolve_repo($c, $c->param('repo')); 23 - _xrpc_error(404, 'RepoNotFound', 'Repository was not found') unless $account; 22 + my $account = resolve_repo($c, $c->param('repo')); 23 + xrpc_error(404, 'RepoNotFound', 'Repository was not found') unless $account; 24 24 assert_repo_readable($c, $account); 25 25 26 26 return { ··· 104 104 }); 105 105 106 106 $registry->register('com.atproto.repo.getRecord', sub ($c, $endpoint) { 107 - my $account = _resolve_repo($c, $c->param('repo')); 108 - _xrpc_error(404, 'RepoNotFound', 'Repository was not found') unless $account; 107 + my $account = resolve_repo($c, $c->param('repo')); 108 + xrpc_error(404, 'RepoNotFound', 'Repository was not found') unless $account; 109 109 assert_repo_readable($c, $account); 110 110 my $row = $c->store->get_record($account->{did}, $c->param('collection'), $c->param('rkey')); 111 - _xrpc_error(404, 'RecordNotFound', 'Record was not found') unless $row; 111 + xrpc_error(404, 'RecordNotFound', 'Record was not found') unless $row; 112 112 assert_record_readable($c, "at://$account->{did}/$row->{collection}/$row->{rkey}"); 113 113 return { 114 114 uri => "at://$account->{did}/$row->{collection}/$row->{rkey}", ··· 118 118 }); 119 119 120 120 $registry->register('com.atproto.repo.listRecords', sub ($c, $endpoint) { 121 - my $account = _resolve_repo($c, $c->param('repo')); 122 - _xrpc_error(404, 'RepoNotFound', 'Repository was not found') unless $account; 121 + my $account = resolve_repo($c, $c->param('repo')); 122 + xrpc_error(404, 'RepoNotFound', 'Repository was not found') unless $account; 123 123 assert_repo_readable( 124 124 $c, 125 125 $account, ··· 150 150 }); 151 151 152 152 $registry->register('com.atproto.repo.uploadBlob', sub ($c, $endpoint) { 153 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 153 + my (undef, $account) = require_auth($c, audience => 'access'); 154 154 assert_repo_writable($c, $account); 155 155 my $bytes = $c->req->body // q(); 156 156 my $cid = ATProto::PDS::Repo::CID->for_raw($bytes)->to_string; 157 157 my $existing = $c->store->get_blob($cid); 158 - _xrpc_error(400, 'BlobTakenDown', 'Blob has been taken down') 158 + xrpc_error(400, 'BlobTakenDown', 'Blob has been taken down') 159 159 if $existing && defined $existing->{quarantined_at}; 160 160 my $data_dir = $c->config_value('data_dir', File::Spec->catdir($c->app->project_root, 'data', 'runtime')); 161 161 my $blob_dir = File::Spec->catdir($data_dir, 'blobs'); 162 162 make_path($blob_dir); 163 163 my $path = File::Spec->catfile($blob_dir, $cid); 164 - open(my $fh, '>:raw', $path) or _xrpc_error(500, 'StorageFailure', "Unable to write blob $cid"); 164 + open(my $fh, '>:raw', $path) or xrpc_error(500, 'StorageFailure', "Unable to write blob $cid"); 165 165 print {$fh} $bytes; 166 166 close($fh); 167 167 ··· 182 182 }); 183 183 184 184 $registry->register('com.atproto.repo.listMissingBlobs', sub ($c, $endpoint) { 185 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 185 + my (undef, $account) = require_auth($c, audience => 'access'); 186 186 assert_repo_writable($c, $account); 187 187 my $page = { 188 188 items => [], ··· 194 194 }); 195 195 196 196 $registry->register('com.atproto.repo.importRepo', sub ($c, $endpoint) { 197 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 197 + my (undef, $account) = require_auth($c, audience => 'access'); 198 198 assert_repo_writable($c, $account); 199 - _xrpc_error(400, 'InvalidRequest', 'Service is not accepting repo imports') 199 + xrpc_error(400, 'InvalidRequest', 'Service is not accepting repo imports') 200 200 unless $c->config_value('accepting_imports', 1); 201 201 my $car_bytes = $c->req->body // q(); 202 - _xrpc_error(400, 'InvalidRequest', 'Repo import requires a CAR payload') 202 + xrpc_error(400, 'InvalidRequest', 'Repo import requires a CAR payload') 203 203 unless length $car_bytes; 204 204 $c->repo_manager->import_repo_car($account, $car_bytes); 205 205 return {}; 206 206 }); 207 207 } 208 208 209 - sub _resolve_repo ($c, $repo) { 210 - return undef unless defined $repo && length $repo; 211 - return $c->store->get_account_by_handle($repo) unless $repo =~ /\Adid:/; 212 - 213 - my $account = $c->store->get_account_by_did($repo); 214 - return $account if $account; 215 - 216 - my $target = lc $repo; 217 - $target =~ s/%3a/:/ig; 218 - for my $row (@{ $c->store->list_accounts }) { 219 - my $candidate = lc($row->{did} // q()); 220 - $candidate =~ s/%3a/:/ig; 221 - return $row if $candidate eq $target; 222 - } 223 - 224 - return undef; 225 - } 226 - 227 209 sub _require_repo_owner ($c, $repo) { 228 - my $account = _resolve_repo($c, $repo); 229 - _xrpc_error(404, 'RepoNotFound', 'Repository was not found') unless $account; 230 - my ($claims) = require_auth($c, audience => 'access', allow_refresh => 1); 231 - _xrpc_error(401, 'AuthRequired', 'Token is not authorized for that repo') unless ($claims->{sub} // '') eq $account->{did}; 210 + my $account = resolve_repo($c, $repo); 211 + xrpc_error(404, 'RepoNotFound', 'Repository was not found') unless $account; 212 + my ($claims) = require_auth($c, audience => 'access'); 213 + xrpc_error(401, 'AuthRequired', 'Token is not authorized for that repo') unless ($claims->{sub} // '') eq $account->{did}; 232 214 assert_repo_writable($c, $account); 233 215 return $account; 234 216 } ··· 266 248 return { 267 249 items => \@visible, 268 250 cursor => $out_cursor, 269 - }; 270 - } 271 - 272 - sub _xrpc_error ($status, $error, $message) { 273 - die { 274 - status => $status, 275 - error => $error, 276 - message => $message, 277 251 }; 278 252 } 279 253
+28 -17
lib/ATProto/PDS/API/Server.pm
··· 161 161 }); 162 162 163 163 $registry->register('com.atproto.server.getSession', sub ($c, $endpoint) { 164 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 164 + my (undef, $account) = require_auth($c, audience => 'access'); 165 165 return session_view($account); 166 166 }); 167 167 ··· 183 183 }); 184 184 185 185 $registry->register('com.atproto.server.checkAccountStatus', sub ($c, $endpoint) { 186 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 186 + my (undef, $account) = require_auth($c, audience => 'access'); 187 187 my $car = $c->store->repo_car($account->{did}); 188 188 my $block_count = 0; 189 189 $block_count = scalar @{ read_car($car)->{blocks} } if defined $car && length $car; ··· 203 203 }); 204 204 205 205 $registry->register('com.atproto.server.createAppPassword', sub ($c, $endpoint) { 206 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 206 + my (undef, $account) = require_auth($c, audience => 'access'); 207 207 my $body = $c->req->json || {}; 208 208 my $name = $body->{name} // q(); 209 209 xrpc_error(400, 'InvalidRequest', 'App password name is required') unless length $name; ··· 225 225 }); 226 226 227 227 $registry->register('com.atproto.server.listAppPasswords', sub ($c, $endpoint) { 228 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 228 + my (undef, $account) = require_auth($c, audience => 'access'); 229 229 my $rows = $c->store->list_app_passwords_by_did($account->{did}); 230 230 return { 231 231 passwords => [ ··· 241 241 }); 242 242 243 243 $registry->register('com.atproto.server.revokeAppPassword', sub ($c, $endpoint) { 244 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 244 + my (undef, $account) = require_auth($c, audience => 'access'); 245 245 my $body = $c->req->json || {}; 246 246 my $name = $body->{name} // q(); 247 247 xrpc_error(400, 'InvalidRequest', 'App password name is required') unless length $name; ··· 252 252 }); 253 253 254 254 $registry->register('com.atproto.server.deactivateAccount', sub ($c, $endpoint) { 255 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 255 + my (undef, $account) = require_auth($c, audience => 'access'); 256 256 $c->store->update_account($account->{did}, deactivated_at => time); 257 257 $c->append_event( 258 258 did => $account->{did}, ··· 267 267 }); 268 268 269 269 $registry->register('com.atproto.server.activateAccount', sub ($c, $endpoint) { 270 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 270 + my (undef, $account) = require_auth($c, audience => 'access'); 271 271 $account = $c->store->update_account($account->{did}, deactivated_at => undef); 272 272 $c->append_event( 273 273 did => $account->{did}, ··· 356 356 }); 357 357 358 358 $registry->register('com.atproto.server.requestEmailConfirmation', sub ($c, $endpoint) { 359 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 359 + my (undef, $account) = require_auth($c, audience => 'access'); 360 360 return {} unless $account->{email}; 361 361 my $token = $c->store->create_action_token( 362 362 did => $account->{did}, ··· 389 389 }); 390 390 391 391 $registry->register('com.atproto.server.requestEmailUpdate', sub ($c, $endpoint) { 392 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 392 + my (undef, $account) = require_auth($c, audience => 'access'); 393 393 my $token_required = defined $account->{email_confirmed_at} ? 1 : 0; 394 394 if ($token_required) { 395 395 my $token = $c->store->create_action_token( ··· 411 411 }); 412 412 413 413 $registry->register('com.atproto.server.updateEmail', sub ($c, $endpoint) { 414 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 414 + my (undef, $account) = require_auth($c, audience => 'access'); 415 415 my $body = $c->req->json || {}; 416 416 if (defined $account->{email_confirmed_at}) { 417 417 xrpc_error(400, 'TokenRequired', 'A confirmation token is required to update email') ··· 433 433 }); 434 434 435 435 $registry->register('com.atproto.server.requestAccountDelete', sub ($c, $endpoint) { 436 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 436 + my (undef, $account) = require_auth($c, audience => 'access'); 437 437 my $token = $c->store->create_action_token( 438 438 did => $account->{did}, 439 439 email => $account->{email}, ··· 450 450 }); 451 451 452 452 $registry->register('com.atproto.server.deleteAccount', sub ($c, $endpoint) { 453 - my ($claims, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 453 + my ($claims, $account) = require_auth($c, audience => 'access'); 454 454 my $body = $c->req->json || {}; 455 455 xrpc_error(401, 'AuthRequired', 'Token is not authorized for that repo') 456 456 unless ($claims->{sub} // q()) eq ($body->{did} // q()) && ($account->{did} // q()) eq ($body->{did} // q()); ··· 485 485 }); 486 486 487 487 $registry->register('com.atproto.server.getServiceAuth', sub ($c, $endpoint) { 488 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 488 + my (undef, $account) = require_auth($c, audience => 'access'); 489 489 my $aud = $c->param('aud') // q(); 490 490 xrpc_error(400, 'InvalidRequest', 'aud is required') unless length $aud; 491 491 my $requested_exp = $c->param('exp'); ··· 523 523 }); 524 524 525 525 $registry->register('com.atproto.server.createInviteCode', sub ($c, $endpoint) { 526 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 526 + my (undef, $account) = require_auth($c, audience => 'access'); 527 527 my $body = $c->req->json || {}; 528 528 my $code = _new_invite_code(); 529 529 $c->store->create_invite_code( ··· 536 536 }); 537 537 538 538 $registry->register('com.atproto.server.createInviteCodes', sub ($c, $endpoint) { 539 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 539 + my (undef, $account) = require_auth($c, audience => 'access'); 540 540 my $body = $c->req->json || {}; 541 541 my @accounts = @{ $body->{forAccounts} || [ $account->{did} ] }; 542 542 my $count = $body->{codeCount} // 1; ··· 562 562 }); 563 563 564 564 $registry->register('com.atproto.server.getAccountInviteCodes', sub ($c, $endpoint) { 565 - my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 565 + my (undef, $account) = require_auth($c, audience => 'access'); 566 566 my $rows = $c->store->list_invite_codes_for_account($account->{did}); 567 567 return { 568 568 codes => [ map { invite_code_view($c->store, $_) } @$rows ], ··· 605 605 || ($opts{allow_refresh} && $aud eq 'refresh'); 606 606 xrpc_error(401, 'InvalidToken', 'Unexpected token audience') unless $ok; 607 607 608 + my $session_id = $claims->{jti} // q(); 609 + xrpc_error(401, 'InvalidToken', 'Token is missing a session identifier') unless length $session_id; 610 + my $session = $c->store->get_session($session_id); 611 + xrpc_error(401, 'InvalidToken', 'Token session was not found') unless $session; 612 + xrpc_error(401, 'ExpiredToken', 'Token session has already been revoked') 613 + if defined $session->{revoked_at}; 614 + xrpc_error(401, 'ExpiredToken', 'Token session has expired') 615 + if defined($session->{expires_at}) && $session->{expires_at} < time; 616 + xrpc_error(401, 'InvalidToken', 'Token session did not match token subject') 617 + unless ($session->{did} // q()) eq ($claims->{sub} // q()); 618 + 608 619 my $account = $c->store->get_account_by_did($claims->{sub}); 609 620 xrpc_error(401, 'InvalidToken', 'Token subject no longer exists') unless $account; 610 621 xrpc_error(401, 'InvalidToken', 'Token subject has been deleted') if defined $account->{deleted_at}; 611 - return ($claims, $account); 622 + return ($claims, $account, $session); 612 623 } 613 624 614 625 sub _issue_session ($c, $account) {
+13
t/repo-api.t
··· 45 45 my $session = $t->tx->res->json; 46 46 my $did = $session->{did}; 47 47 my $access = $session->{accessJwt}; 48 + my $refresh = $session->{refreshJwt}; 48 49 49 50 $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { Authorization => "Bearer $access" } => json => { 50 51 repo => $did, ··· 57 58 }, 58 59 })->status_is(200) 59 60 ->json_like('/cid' => qr/\Ab/); 61 + 62 + $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { Authorization => "Bearer $refresh" } => json => { 63 + repo => $did, 64 + collection => 'app.bsky.feed.post', 65 + rkey => 'refresh-post', 66 + record => { 67 + '$type' => 'app.bsky.feed.post', 68 + text => 'refresh tokens are not access tokens', 69 + createdAt => '2026-03-10T00:01:00Z', 70 + }, 71 + })->status_is(401) 72 + ->json_is('/error' => 'InvalidToken'); 60 73 61 74 $t->get_ok("/xrpc/com.atproto.repo.getRecord?repo=$did&collection=app.bsky.feed.post&rkey=first-post") 62 75 ->status_is(200)
+23 -1
t/server-auth.t
··· 98 98 99 99 my $refreshed = $t->tx->res->json; 100 100 101 + $t->get_ok('/xrpc/com.atproto.server.getSession' => { Authorization => "Bearer $access" }) 102 + ->status_is(401) 103 + ->json_is('/error' => 'ExpiredToken'); 104 + 105 + $t->get_ok('/xrpc/com.atproto.server.getSession' => { Authorization => "Bearer $refresh" }) 106 + ->status_is(401) 107 + ->json_is('/error' => 'InvalidToken'); 108 + 109 + $t->get_ok('/xrpc/com.atproto.server.getSession' => { Authorization => "Bearer $refreshed->{refreshJwt}" }) 110 + ->status_is(401) 111 + ->json_is('/error' => 'InvalidToken'); 112 + 113 + $t->get_ok('/xrpc/com.atproto.server.getSession' => { Authorization => "Bearer $refreshed->{accessJwt}" }) 114 + ->status_is(200) 115 + ->json_is('/did' => $did); 116 + 101 117 $t->post_ok('/xrpc/com.atproto.server.deleteSession' => { Authorization => "Bearer $refreshed->{refreshJwt}" } => json => {}) 102 118 ->status_is(200); 103 119 120 + $t->get_ok('/xrpc/com.atproto.server.getSession' => { Authorization => "Bearer $refreshed->{accessJwt}" }) 121 + ->status_is(401) 122 + ->json_is('/error' => 'ExpiredToken'); 123 + 104 124 $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 105 125 identifier => 'alice.localhost', 106 126 password => 'password123', 107 127 })->status_is(200) 108 128 ->json_is('/did' => $did); 109 129 130 + my $replacement_access = $t->tx->res->json->{accessJwt}; 131 + 110 132 $t->get_ok('/xrpc/com.atproto.server.getServiceAuth?aud=did:web:api.bsky.app&lxm=app.bsky.actor.getPreferences' => { 111 - Authorization => "Bearer $access", 133 + Authorization => "Bearer $replacement_access", 112 134 })->status_is(200) 113 135 ->json_has('/token'); 114 136