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.

Expand OAuth include permission sets

alice 812a63f9 c8d6a678

+414 -1
+165 -1
lib/ATProto/PDS/Auth/OAuth.pm
··· 19 19 use ATProto::PDS::API::Util qw(xrpc_error); 20 20 use ATProto::PDS::Auth::JWT qw(decode_jwt encode_jwt); 21 21 use ATProto::PDS::Auth::OAuthScope qw( 22 + oauth_expand_scope 22 23 oauth_normalize_scope 23 24 oauth_scope_allows 24 25 oauth_scope_has_atproto ··· 31 32 use ATProto::PDS::Util::BaseX qw(base64url_decode base64url_encode); 32 33 33 34 our @EXPORT_OK = qw( 35 + oauth_expand_scope 34 36 oauth_normalize_scope 35 37 oauth_scope_allows 36 38 oauth_scope_allows_permission ··· 129 131 unless defined $scope; 130 132 return _oauth_json_error($c, 400, 'invalid_scope', 'scope must include atproto') 131 133 unless oauth_scope_has_atproto($scope); 134 + my $compiled_scope = eval { $self->_compile_token_scope($c, $scope) }; 135 + return _oauth_json_error($c, 400, 'invalid_scope', "$@") if $@; 132 136 return _oauth_json_error($c, 400, 'invalid_request', 'redirect_uri is required') 133 137 unless length $redirect_uri; 134 138 return _oauth_json_error($c, 400, 'invalid_request', 'redirect_uri is not registered') ··· 151 155 client_name => $client->{client_name}, 152 156 client_uri => $client->{client_uri}, 153 157 redirect_uri => $redirect_uri, 154 - scope => $scope, 158 + scope => $compiled_scope, 155 159 state => $body->{state}, 156 160 nonce => $body->{nonce}, 157 161 login_hint => $body->{login_hint}, ··· 693 697 sub _issuer ($self) { 694 698 my $base = $self->settings->{base_url} // 'http://127.0.0.1:7755'; 695 699 return Mojo::URL->new($base)->path('')->query(undef)->fragment(undef)->to_string =~ s{/\z}{}r; 700 + } 701 + 702 + sub _compile_token_scope ($self, $c, $scope) { 703 + my $expanded = oauth_expand_scope($scope, sub ($include) { 704 + return $self->_permission_scopes_for_include($c, $include); 705 + }); 706 + die 'scope contains unsupported values' unless defined $expanded; 707 + return $expanded; 708 + } 709 + 710 + sub _permission_scopes_for_include ($self, $c, $include) { 711 + my $permission_set = $self->_load_permission_set($c, $include->{nsid}); 712 + die 'unable to retrieve permission sets' 713 + unless ref($permission_set) eq 'HASH'; 714 + 715 + my $authority = _include_authority($include->{nsid}); 716 + die 'unable to retrieve permission sets' 717 + unless defined $authority && length $authority; 718 + 719 + my @scopes; 720 + for my $permission (@{ $permission_set->{permissions} || [] }) { 721 + next unless ref($permission) eq 'HASH'; 722 + next unless ($permission->{type} // q()) eq 'permission'; 723 + if (($permission->{resource} // q()) eq 'repo') { 724 + my $scope = _repo_scope_from_permission($authority, $permission); 725 + push @scopes, $scope if defined $scope; 726 + next; 727 + } 728 + if (($permission->{resource} // q()) eq 'rpc') { 729 + my $scope = _rpc_scope_from_permission($authority, $include, $permission); 730 + push @scopes, $scope if defined $scope; 731 + next; 732 + } 733 + } 734 + 735 + return \@scopes; 736 + } 737 + 738 + sub _load_permission_set ($self, $c, $nsid) { 739 + state %cache; 740 + return $cache{$nsid} if exists $cache{$nsid}; 741 + 742 + my $local = $c->app->lexicons->get($nsid); 743 + if (_is_permission_set_lexicon($local, $nsid)) { 744 + return $cache{$nsid} = $local->{defs}{main}; 745 + } 746 + 747 + my $authority_handle = _nsid_authority_handle($nsid); 748 + return $cache{$nsid} = undef unless defined $authority_handle && length $authority_handle; 749 + 750 + my $appview_url = $c->config_value('bsky_appview_url', 'https://api.bsky.app'); 751 + return $cache{$nsid} = undef unless defined $appview_url && length $appview_url; 752 + 753 + my $url = Mojo::URL->new($appview_url)->path('/xrpc/com.atproto.repo.getRecord')->query( 754 + repo => $authority_handle, 755 + collection => 'com.atproto.lexicon.schema', 756 + rkey => $nsid, 757 + ); 758 + my $tx = eval { $self->ua->get($url => { 'Accept-Encoding' => 'identity' }) }; 759 + return $cache{$nsid} = undef if $@ || !$tx; 760 + 761 + my $res = $tx->result; 762 + return $cache{$nsid} = undef unless $res && $res->is_success; 763 + my $json = $res->json; 764 + my $value = ref($json) eq 'HASH' ? $json->{value} : undef; 765 + return $cache{$nsid} = undef unless _is_permission_set_lexicon($value, $nsid); 766 + return $cache{$nsid} = $value->{defs}{main}; 767 + } 768 + 769 + sub _is_permission_set_lexicon ($lexicon, $nsid) { 770 + return 0 unless ref($lexicon) eq 'HASH'; 771 + return 0 unless ($lexicon->{id} // q()) eq $nsid; 772 + return 0 unless ref($lexicon->{defs}) eq 'HASH'; 773 + return 0 unless ref($lexicon->{defs}{main}) eq 'HASH'; 774 + return 0 unless ($lexicon->{defs}{main}{type} // q()) eq 'permission-set'; 775 + return 1; 776 + } 777 + 778 + sub _repo_scope_from_permission ($authority, $permission) { 779 + my @collections = _authority_scoped_nsids($authority, $permission->{collection}); 780 + return undef unless @collections; 781 + 782 + my @actions = ref($permission->{action}) eq 'ARRAY' 783 + ? @{ $permission->{action} } 784 + : defined($permission->{action}) ? ($permission->{action}) : qw(create update delete); 785 + my %valid_action = map { $_ => 1 } qw(create update delete); 786 + return undef if grep { !$valid_action{$_} } @actions; 787 + 788 + my %seen_action; 789 + @actions = grep { !$seen_action{$_}++ } @actions; 790 + my %default = map { $_ => 1 } qw(create update delete); 791 + my $default_actions = @actions == 3 && !grep { !$default{$_} } @actions; 792 + 793 + my $scope = @collections == 1 794 + ? 'repo:' . $collections[0] 795 + : do { 796 + my $params = Mojo::Parameters->new; 797 + $params->append(collection => $_) for @collections; 798 + 'repo?' . $params->to_string; 799 + }; 800 + return $scope if $default_actions; 801 + 802 + my $params = Mojo::Parameters->new; 803 + if ($scope =~ /\?(.+)\z/) { 804 + $params = Mojo::Parameters->new($1); 805 + $scope =~ s/\?.+\z//; 806 + } 807 + $params->append(action => $_) for sort @actions; 808 + return $scope . '?' . $params->to_string; 809 + } 810 + 811 + sub _rpc_scope_from_permission ($authority, $include, $permission) { 812 + my @lxm = _authority_scoped_nsids($authority, $permission->{lxm}); 813 + return undef unless @lxm; 814 + 815 + my $aud; 816 + if ($permission->{inheritAud}) { 817 + return undef if defined($permission->{aud}) && length($permission->{aud}); 818 + $aud = $include->{aud}; 819 + } else { 820 + $aud = $permission->{aud}; 821 + return undef unless defined($aud) && length($aud) && $aud eq '*'; 822 + } 823 + return undef unless defined($aud) && length($aud); 824 + my $scope = @lxm == 1 ? 'rpc:' . $lxm[0] : 'rpc'; 825 + my $params = Mojo::Parameters->new; 826 + if (@lxm > 1) { 827 + $params->append(lxm => $_) for @lxm; 828 + } 829 + $params->append(aud => $aud); 830 + return $scope . '?' . $params->to_string; 831 + } 832 + 833 + sub _authority_scoped_nsids ($authority, $value) { 834 + my @values = ref($value) eq 'ARRAY' 835 + ? @$value 836 + : defined($value) ? ($value) : (); 837 + return grep { _nsid_within_authority($authority, $_) } @values; 838 + } 839 + 840 + sub _include_authority ($nsid) { 841 + return undef unless defined($nsid) && $nsid =~ /\./; 842 + return $nsid =~ s/\.[^.]+\z//r; 843 + } 844 + 845 + sub _nsid_authority_handle ($nsid) { 846 + my $authority = _include_authority($nsid); 847 + return undef unless defined $authority && length $authority; 848 + my @parts = split /\./, $authority; 849 + return undef unless @parts >= 2; 850 + return join '.', reverse @parts; 851 + } 852 + 853 + sub _nsid_within_authority ($authority, $value) { 854 + return 0 unless defined($authority) && length($authority); 855 + return 0 unless defined($value) && length($value); 856 + return 0 if $value eq '*'; 857 + return 0 unless $value =~ /\A[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+\z/; 858 + return 0 unless length($value) > length($authority) + 1; 859 + return $value =~ /\A\Q$authority\E\./ ? 1 : 0; 696 860 } 697 861 698 862 sub _jwt_secret ($self) {
+73
lib/ATProto/PDS/Auth/OAuthScope.pm
··· 10 10 use Mojo::Util qw(url_unescape); 11 11 12 12 our @EXPORT_OK = qw( 13 + oauth_expand_scope 13 14 oauth_normalize_scope 14 15 oauth_scope_allows 15 16 oauth_scope_allows_permission ··· 41 42 return _parse_scope($scope)->{static}{atproto} ? 1 : 0; 42 43 } 43 44 45 + sub oauth_expand_scope ($scope, $resolver) { 46 + my $normalized = oauth_normalize_scope($scope); 47 + return undef unless defined $normalized; 48 + return $normalized unless $normalized =~ /\binclude:/; 49 + return undef unless ref($resolver) eq 'CODE'; 50 + 51 + my %seen; 52 + my @expanded; 53 + for my $token (grep { length } split /\s+/, $normalized) { 54 + my $include = _include_scope_from_token($token); 55 + if ($include) { 56 + my $scopes = $resolver->($include); 57 + return undef unless ref($scopes) eq 'ARRAY'; 58 + for my $scope_token (@$scopes) { 59 + my $normalized_scope = _normalize_scope_token($scope_token); 60 + return undef unless defined $normalized_scope; 61 + return undef if _include_scope_from_token($normalized_scope); 62 + next if $seen{$normalized_scope}++; 63 + push @expanded, $normalized_scope; 64 + } 65 + next; 66 + } 67 + 68 + next if $seen{$token}++; 69 + push @expanded, $token; 70 + } 71 + 72 + return join ' ', sort @expanded; 73 + } 74 + 44 75 sub oauth_scope_allows ($scope, $required_scope) { 45 76 return 1 if !defined($required_scope) || !length($required_scope); 46 77 my $normalized = oauth_normalize_scope($scope); ··· 95 126 my $parsed = _parse_identity_scope($positional, $params) or return undef; 96 127 return _format_identity_scope($parsed); 97 128 } 129 + if ($prefix eq 'include') { 130 + my $parsed = _parse_include_scope($positional, $params) or return undef; 131 + return _format_include_scope($parsed); 132 + } 98 133 if ($prefix eq 'repo') { 99 134 my $parsed = _parse_repo_scope($positional, $params) or return undef; 100 135 return _format_repo_scope($parsed); ··· 115 150 static => {}, 116 151 account => [], 117 152 blob => [], 153 + include => [], 118 154 identity => [], 119 155 repo => [], 120 156 rpc => [], ··· 144 180 push @{ $parsed->{blob} }, $entry if $entry; 145 181 next; 146 182 } 183 + if ($prefix eq 'include') { 184 + my $entry = _parse_include_scope($positional, $params); 185 + push @{ $parsed->{include} }, $entry if $entry; 186 + next; 187 + } 147 188 if ($prefix eq 'identity') { 148 189 my $entry = _parse_identity_scope($positional, $params); 149 190 push @{ $parsed->{identity} }, $entry if $entry; ··· 242 283 return unless _allowed_params($params); 243 284 return { 244 285 attr => $positional, 286 + }; 287 + } 288 + 289 + sub _parse_include_scope ($positional, $params) { 290 + return unless defined $positional && length $positional; 291 + return unless _is_nsid($positional); 292 + return unless _allowed_params($params, 'aud'); 293 + my $aud = _single_param($params, 'aud'); 294 + return unless !defined($aud) || _is_atproto_audience($aud); 295 + return { 296 + nsid => $positional, 297 + (defined($aud) ? (aud => $aud) : ()), 245 298 }; 246 299 } 247 300 ··· 442 495 return "identity:$parsed->{attr}"; 443 496 } 444 497 498 + sub _format_include_scope ($parsed) { 499 + my $scope = "include:$parsed->{nsid}"; 500 + return $scope unless defined $parsed->{aud} && length $parsed->{aud}; 501 + my $params = Mojo::Parameters->new; 502 + $params->append(aud => $parsed->{aud}); 503 + return $scope . '?' . $params->to_string; 504 + } 505 + 445 506 sub _format_repo_scope ($parsed) { 446 507 my $scope = @{ $parsed->{collection} } == 1 447 508 ? 'repo:' . $parsed->{collection}[0] ··· 492 553 return $value =~ /\A[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+\z/ ? 1 : 0; 493 554 } 494 555 556 + sub _is_atproto_audience ($value) { 557 + return 0 unless defined($value) && length($value); 558 + return 0 if $value =~ /\s/; 559 + return $value =~ /\Adid:[a-z0-9]+:[^?#\s]+#[A-Za-z0-9._:-]+\z/i ? 1 : 0; 560 + } 561 + 495 562 sub _is_accept ($value) { 496 563 return 0 unless defined($value) && length($value); 497 564 return 1 if $value eq '*/*'; ··· 515 582 return 1 if $accept eq $mime; 516 583 } 517 584 return 0; 585 + } 586 + 587 + sub _include_scope_from_token ($token) { 588 + my ($prefix, $positional, $params) = _scope_syntax($token); 589 + return undef unless defined $prefix && $prefix eq 'include'; 590 + return _parse_include_scope($positional, $params); 518 591 } 519 592 520 593 1;
+115
t/oauth-include.t
··· 1 + use v5.34; 2 + use warnings; 3 + 4 + use Config (); 5 + use File::Spec; 6 + use FindBin qw($Bin); 7 + use Test::More; 8 + 9 + BEGIN { 10 + require lib; 11 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 12 + lib->import( 13 + File::Spec->catdir($root, 'lib'), 14 + File::Spec->catdir($root, 'local', 'lib', 'perl5'), 15 + File::Spec->catdir($root, 'local', 'lib', 'perl5', $Config::Config{archname}), 16 + ); 17 + } 18 + 19 + use ATProto::PDS::Auth::OAuth; 20 + use ATProto::PDS::Auth::OAuthScope qw(oauth_scope_allows_permission); 21 + 22 + { 23 + package OAuthIncludeTestContext; 24 + 25 + sub new { 26 + my ($class) = @_; 27 + return bless {}, $class; 28 + } 29 + } 30 + 31 + my $oauth = ATProto::PDS::Auth::OAuth->new(settings => { 32 + base_url => 'https://perlsky.example', 33 + jwt_secret => 'test-secret', 34 + }); 35 + my $context = OAuthIncludeTestContext->new; 36 + 37 + { 38 + no warnings 'redefine'; 39 + local *ATProto::PDS::Auth::OAuth::_load_permission_set = sub { 40 + my (undef, undef, $nsid) = @_; 41 + return { 42 + permissions => [ 43 + { 44 + type => 'permission', 45 + resource => 'rpc', 46 + inheritAud => 1, 47 + lxm => [ 48 + 'app.bsky.notification.getPreferences', 49 + 'app.bsky.notification.updateSeen', 50 + 'chat.bsky.convo.getMessages', 51 + ], 52 + }, 53 + { 54 + type => 'permission', 55 + resource => 'repo', 56 + action => ['create'], 57 + collection => [ 58 + 'app.bsky.feed.post', 59 + 'com.atproto.server.createSession', 60 + ], 61 + }, 62 + ], 63 + } if $nsid eq 'app.bsky.authManageNotifications'; 64 + return undef; 65 + }; 66 + 67 + my $compiled = $oauth->_compile_token_scope( 68 + $context, 69 + 'atproto include:app.bsky.authManageNotifications?aud=did:web:api.bsky.app#bsky_appview', 70 + ); 71 + 72 + like( 73 + $compiled, 74 + qr/\Aatproto\b/, 75 + 'compiled include scope preserves the atproto marker', 76 + ); 77 + ok( 78 + oauth_scope_allows_permission( 79 + $compiled, 80 + type => 'rpc', 81 + aud => 'did:web:api.bsky.app#bsky_appview', 82 + lxm => 'app.bsky.notification.getPreferences', 83 + ), 84 + 'compiled scope allows included RPC permissions with inherited audience', 85 + ); 86 + ok( 87 + oauth_scope_allows_permission( 88 + $compiled, 89 + type => 'repo', 90 + action => 'create', 91 + collection => 'app.bsky.feed.post', 92 + ), 93 + 'compiled scope allows included repo permissions under the same authority', 94 + ); 95 + ok( 96 + !oauth_scope_allows_permission( 97 + $compiled, 98 + type => 'rpc', 99 + aud => 'did:web:api.bsky.chat#bsky_chat', 100 + lxm => 'chat.bsky.convo.getMessages', 101 + ), 102 + 'compiled scope drops out-of-authority RPC permissions from permission sets', 103 + ); 104 + ok( 105 + !oauth_scope_allows_permission( 106 + $compiled, 107 + type => 'repo', 108 + action => 'create', 109 + collection => 'com.atproto.server.createSession', 110 + ), 111 + 'compiled scope drops out-of-authority repo permissions from permission sets', 112 + ); 113 + } 114 + 115 + done_testing;
+34
t/oauth-permissions.t
··· 66 66 unless $client_id eq $client_metadata->{client_id}; 67 67 return $client_metadata; 68 68 }; 69 + local *ATProto::PDS::Auth::OAuth::_load_permission_set = sub ($self, $c, $nsid) { 70 + return { 71 + permissions => [ 72 + { 73 + type => 'permission', 74 + resource => 'rpc', 75 + inheritAud => JSON::PP::true, 76 + lxm => [ 77 + 'app.bsky.notification.getPreferences', 78 + 'app.bsky.notification.updateSeen', 79 + ], 80 + }, 81 + ], 82 + } if $nsid eq 'app.bsky.authManageNotifications'; 83 + return undef; 84 + }; 69 85 70 86 my $t = Test::Mojo->new(ATProto::PDS->new( 71 87 project_root => $root, ··· 157 173 $config->{base_url} . '/xrpc/app.bsky.actor.getPreferences', 158 174 ))->status_is(200) 159 175 ->json_is('/preferences' => []); 176 + 177 + my $notifications_include = _oauth_tokens_for_scope( 178 + $t, 179 + $did, 180 + 'atproto include:app.bsky.authManageNotifications?aud=did:web:api.bsky.app#bsky_appview', 181 + ); 182 + $t->get_ok('/xrpc/app.bsky.notification.getPreferences' => _oauth_headers( 183 + $notifications_include->{access_token}, 184 + 'GET', 185 + $config->{base_url} . '/xrpc/app.bsky.notification.getPreferences', 186 + ))->status_is(200) 187 + ->json_has('/preferences'); 188 + $t->get_ok('/xrpc/app.bsky.actor.getPreferences' => _oauth_headers( 189 + $notifications_include->{access_token}, 190 + 'GET', 191 + $config->{base_url} . '/xrpc/app.bsky.actor.getPreferences', 192 + ))->status_is(403) 193 + ->json_like('/message' => qr/rpc:app\.bsky\.actor\.getPreferences\?aud=did:web:api\.bsky\.app#bsky_appview/); 160 194 161 195 $t->get_ok("/xrpc/com.atproto.server.getServiceAuth?aud=$chat_aud&lxm=chat.bsky.convo.getMessages" => _oauth_headers( 162 196 $transition_generic->{access_token},
+27
t/oauth-scopes.t
··· 17 17 } 18 18 19 19 use ATProto::PDS::Auth::OAuthScope qw( 20 + oauth_expand_scope 20 21 oauth_normalize_scope 21 22 oauth_scope_allows_permission 22 23 oauth_scope_has_atproto ··· 27 28 oauth_normalize_scope('atproto repo:app.bsky.feed.post?action=create&action=update transition:generic'), 28 29 'atproto repo:app.bsky.feed.post?action=create&action=update transition:generic', 29 30 'normalization preserves supported scope values', 31 + ); 32 + is( 33 + oauth_normalize_scope('atproto include:app.bsky.authManageNotifications?aud=did:web:api.bsky.app#bsky_appview'), 34 + 'atproto include:app.bsky.authManageNotifications?aud=did%3Aweb%3Aapi.bsky.app%23bsky_appview', 35 + 'normalization accepts include scopes and canonicalizes the audience encoding', 36 + ); 37 + ok( 38 + !defined oauth_normalize_scope('atproto include:app.bsky.authManageNotifications?aud=did:web:api.bsky.app'), 39 + 'include scopes require a full atproto audience', 40 + ); 41 + 42 + is( 43 + oauth_expand_scope( 44 + 'atproto include:app.bsky.authManageNotifications?aud=did:web:api.bsky.app#bsky_appview', 45 + sub { 46 + my ($include) = @_; 47 + is($include->{nsid}, 'app.bsky.authManageNotifications', 'include resolver receives the lexicon nsid'); 48 + is($include->{aud}, 'did:web:api.bsky.app#bsky_appview', 'include resolver receives the decoded audience'); 49 + return [ 50 + 'rpc:app.bsky.notification.getPreferences?aud=did:web:api.bsky.app#bsky_appview', 51 + 'rpc:app.bsky.notification.updateSeen?aud=did:web:api.bsky.app#bsky_appview', 52 + ]; 53 + }, 54 + ), 55 + 'atproto rpc:app.bsky.notification.getPreferences?aud=did%3Aweb%3Aapi.bsky.app%23bsky_appview rpc:app.bsky.notification.updateSeen?aud=did%3Aweb%3Aapi.bsky.app%23bsky_appview', 56 + 'include scopes expand into concrete permission scopes', 30 57 ); 31 58 32 59 ok(oauth_scope_has_atproto('atproto transition:generic'), 'atproto marker is detected');