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.

Accept user service auth for blob uploads

alice e54b0a07 7d333ebc

+90 -14
+6 -2
lib/ATProto/PDS/API/Repo.pm
··· 11 11 use JSON::PP (); 12 12 use Mojo::URL; 13 13 14 - use ATProto::PDS::API::Server qw(require_auth); 14 + use ATProto::PDS::API::Server qw(require_access_or_service_auth require_auth); 15 15 use ATProto::PDS::API::Util qw(blob_ref resolve_repo xrpc_error); 16 16 use ATProto::PDS::Auth::OAuth qw( 17 17 oauth_required_permission_scope ··· 120 120 }); 121 121 122 122 $registry->register('com.atproto.repo.uploadBlob', sub ($c, $endpoint) { 123 - my ($claims, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 123 + my ($claims, $account) = require_access_or_service_auth( 124 + $c, 125 + audience => TOKEN_AUD_ACCESS, 126 + lxm => $endpoint->{id}, 127 + ); 124 128 assert_repo_writable($c, $account); 125 129 my $bytes = $c->req->body // q(); 126 130 my $mime_type = $c->req->headers->content_type || 'application/octet-stream';
+58 -12
lib/ATProto/PDS/API/Server.pm
··· 38 38 use ATProto::PDS::Repo::CAR qw(read_car); 39 39 use ATProto::PDS::Util::BaseX qw(base64url_decode decode_base58btc); 40 40 41 - our @EXPORT_OK = qw(register_server_handlers require_auth session_view); 41 + our @EXPORT_OK = qw(register_server_handlers require_auth require_access_or_service_auth session_view); 42 42 43 43 my %PROTECTED_SERVICE_AUTH_METHOD = map { lc($_) => 1 } qw( 44 44 com.atproto.identity.requestPlcOperationSignature ··· 786 786 return ($claims, $account, $session); 787 787 } 788 788 789 + sub require_access_or_service_auth ($c, %opts) { 790 + my $auth = $c->req->headers->authorization // q(); 791 + if ($auth =~ /\ABearer\s+(.+)\z/i) { 792 + my $token = $1; 793 + if (my $claims = _parse_service_auth_claims($token)) { 794 + my $lxm = $opts{lxm} // q(); 795 + xrpc_error(401, 'InvalidToken', 'Unexpected token audience') 796 + if ($opts{audience} // TOKEN_AUD_ACCESS) eq TOKEN_AUD_REFRESH; 797 + my $account = _verify_user_service_auth($c, $token, $claims, $lxm); 798 + return ($claims, $account, undef); 799 + } 800 + } 801 + return require_auth($c, %opts); 802 + } 803 + 789 804 sub _jwt_decode_error ($message) { 790 805 return ('ExpiredToken', 'Token has expired') 791 806 if $message =~ /expired/i; ··· 819 834 return undef unless $auth =~ /\ABearer\s+(.+)\z/i; 820 835 my $token = $1; 821 836 822 - my ($header_b64, $claims_b64, $sig_b64) = split /\./, $token, 3; 837 + my $claims = _parse_service_auth_claims($token) or return undef; 838 + return undef unless _same_did(($claims->{iss} // q()), $did); 839 + return undef unless _audience_matches_service($c, $claims->{aud}); 840 + return undef unless lc($claims->{lxm} // q()) eq lc($lxm // q()); 841 + return undef unless _verify_service_auth_signature($c, $token, $claims->{iss}); 842 + 843 + return $claims->{iss}; 844 + } 845 + 846 + sub _verify_user_service_auth ($c, $token, $claims, $lxm) { 847 + xrpc_error(401, 'InvalidToken', 'Token subject is invalid') 848 + unless defined($claims->{iss}) && ($claims->{iss} // q()) =~ /\Adid:/; 849 + xrpc_error(401, 'InvalidToken', 'Unexpected token audience') 850 + unless _audience_matches_service($c, $claims->{aud}); 851 + xrpc_error(401, 'InvalidToken', 'Token method did not match request') 852 + unless lc($claims->{lxm} // q()) eq lc($lxm // q()); 853 + xrpc_error(401, 'InvalidToken', 'Token signature is invalid') 854 + unless _verify_service_auth_signature($c, $token, $claims->{iss}); 855 + 856 + my $account = $c->store->get_account_by_did($claims->{iss}); 857 + xrpc_error(401, 'InvalidToken', 'Token subject no longer exists') unless $account; 858 + xrpc_error(401, 'InvalidToken', 'Token subject has been deleted') if defined $account->{deleted_at}; 859 + return $account; 860 + } 861 + 862 + sub _parse_service_auth_claims ($token) { 863 + my ($header_b64, $claims_b64, $sig_b64) = split /\./, ($token // q()), 3; 823 864 return undef unless defined $sig_b64; 824 865 825 866 my $header = eval { JSON::PP::decode_json(base64url_decode($header_b64)) }; ··· 828 869 829 870 my $claims = eval { JSON::PP::decode_json(base64url_decode($claims_b64)) }; 830 871 return undef if $@ || ref($claims) ne 'HASH'; 831 - return undef unless _same_did(($claims->{iss} // q()), $did); 832 872 833 873 my $now = time; 834 874 return undef if defined($claims->{nbf}) && $claims->{nbf} > $now; 835 875 return undef if defined($claims->{iat}) && $claims->{iat} > ($now + 60); 836 876 return undef if defined($claims->{exp}) && $claims->{exp} <= $now; 837 - return undef unless _audience_matches_service($c, $claims->{aud}); 838 - return undef unless lc($claims->{lxm} // q()) eq lc($lxm // q()); 877 + return $claims; 878 + } 839 879 840 - my $did_doc = _resolve_migration_did_doc($c, $did) or return undef; 841 - my $public_key = _did_doc_atproto_public_key($did_doc) or return undef; 880 + sub _verify_service_auth_signature ($c, $token, $did) { 881 + my ($header_b64, $claims_b64, $sig_b64) = split /\./, ($token // q()), 3; 882 + return 0 unless defined $sig_b64; 883 + my $did_doc = _resolve_migration_did_doc($c, $did) or return 0; 884 + my $public_key = _did_doc_atproto_public_key($did_doc) or return 0; 842 885 843 886 my $pk = eval { 844 887 my $ecc = Crypt::PK::ECC->new; 845 888 $ecc->import_key_raw($public_key, 'secp256k1'); 846 889 $ecc; 847 890 }; 848 - return undef if $@ || !$pk; 891 + return 0 if $@ || !$pk; 849 892 850 893 my $verified = eval { 851 894 $pk->verify_message_rfc7518(base64url_decode($sig_b64), "$header_b64.$claims_b64", 'SHA256'); 852 895 }; 853 - return undef if $@ || !$verified; 854 - 855 - return $claims->{iss}; 896 + return $@ || !$verified ? 0 : 1; 856 897 } 857 898 858 899 sub _audience_matches_service ($c, $aud) { 859 - my %acceptable = map { ($_ // q()) => 1 } grep { defined && length } ( 900 + my %acceptable = map { 901 + my $value = $_ // q(); 902 + my $decoded = $value; 903 + $decoded =~ s/%3a/:/ig; 904 + ($value => 1, $decoded => 1); 905 + } grep { defined && length } ( 860 906 service_did($c->app->settings), 861 907 $c->config_value('base_url'), 862 908 );
+26
t/repo-api.t
··· 22 22 23 23 use Test::Mojo; 24 24 use ATProto::PDS; 25 + use ATProto::PDS::Identity qw(service_did); 25 26 use ATProto::PDS::Repo::CAR qw(read_car); 26 27 27 28 my @mock_pids; ··· 82 83 my $did = $session->{did}; 83 84 my $access = $session->{accessJwt}; 84 85 my $refresh = $session->{refreshJwt}; 86 + my $service_did = service_did($t->app->settings); 85 87 86 88 $t->get_ok("/xrpc/com.atproto.repo.describeRepo?repo=$did") 87 89 ->status_is(200) ··· 298 300 ->json_like('/cid' => qr/\Ab/) 299 301 ->json_has('/rev'); 300 302 my $latest_commit_cid = $t->tx->res->json->{cid}; 303 + 304 + $t->get_ok('/xrpc/com.atproto.server.getServiceAuth?aud=' . $service_did . '&lxm=com.atproto.repo.uploadBlob' => { 305 + Authorization => "Bearer $access", 306 + })->status_is(200) 307 + ->json_has('/token'); 308 + my $upload_service_auth = $t->tx->res->json->{token}; 309 + 310 + $t->post_ok('/xrpc/com.atproto.repo.uploadBlob' => { 311 + Authorization => "Bearer $upload_service_auth", 312 + 'Content-Type' => 'text/plain', 313 + } => 'service-auth-blob')->status_is(200) 314 + ->json_has('/blob/ref/$link'); 315 + 316 + $t->get_ok('/xrpc/com.atproto.server.getServiceAuth?aud=' . $service_did . '&lxm=app.bsky.actor.getPreferences' => { 317 + Authorization => "Bearer $access", 318 + })->status_is(200) 319 + ->json_has('/token'); 320 + my $wrong_service_auth = $t->tx->res->json->{token}; 321 + 322 + $t->post_ok('/xrpc/com.atproto.repo.uploadBlob' => { 323 + Authorization => "Bearer $wrong_service_auth", 324 + 'Content-Type' => 'text/plain', 325 + } => 'wrong-lxm-service-auth')->status_is(401) 326 + ->json_is('/error' => 'InvalidToken'); 301 327 302 328 $t->get_ok("/xrpc/com.atproto.sync.getRecord?did=$did&collection=app.bsky.feed.post&rkey=first-post") 303 329 ->status_is(200)