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.

Align app password session semantics

alice 12563f3e fe46c447

+196 -17
+1
docs/TEST_AUDIT.md
··· 56 56 - Remote `did:plc` DID docs should resolve through the PLC directory defaults even when `plc_url` is not explicitly configured; gating that path on local config silently breaks federated identity lookups. 57 57 - `com.atproto.repo.getRecord` must honor `cid` when present, and `putRecord` / `deleteRecord` must actually enforce `swapRecord`; those negative edges are now covered directly. 58 58 - `com.atproto.repo.createRecord` follows the reference runtime by ignoring a stray `swapRecord` field, and direct reference coverage now pins `putRecord` / `deleteRecord` `swapCommit` and `swapRecord` mismatch semantics explicitly. 59 + - App-password sessions follow the official runtime more closely than the older local assumptions did: access-token scopes use the `com.atproto.appPass` / `com.atproto.appPassPrivileged` names, standard app-password sessions may list app passwords, privileged-only `getServiceAuth` failures report `InvalidRequest`, and revoked refresh tokens on `refreshSession` fail with `400 ExpiredToken`. 59 60 - `com.atproto.server.requestPasswordReset` and `com.atproto.server.deleteAccount` now follow the reference form-token flow, with focused regression coverage for missing-account and bearerless deletion semantics. 60 61 - `com.atproto.server.createAccount` with an explicit `did` must behave like an authenticated migration flow: require auth from that same DID, keep the existing DID document, and start the new account deactivated until activation catches the DID document up to the new PDS. 61 62 - `com.atproto.server.checkAccountStatus` must validate the stored DID document against the PDS service endpoint and signing key, and `com.atproto.repo.describeRepo` must derive `didDoc` / `handleIsCorrect` from that document instead of hardcoding success.
+1 -1
lib/ATProto/PDS/API/Helpers.pm
··· 70 70 if (verify_password($password, $salt, $hash)) { 71 71 return { 72 72 kind => 'app_password', 73 - scope => $app_password->{privileged} ? 'app_password_privileged' : 'app_password', 73 + scope => $app_password->{privileged} ? 'com.atproto.appPassPrivileged' : 'com.atproto.appPass', 74 74 app_password_name => $app_password->{name}, 75 75 }; 76 76 }
+19 -6
lib/ATProto/PDS/API/Server.pm
··· 242 242 }); 243 243 244 244 $registry->register('com.atproto.server.refreshSession', sub ($c, $endpoint) { 245 - my (undef, $account, $session) = require_auth($c, audience => TOKEN_AUD_REFRESH); 245 + my (undef, $account, $session); 246 + my $ok = eval { 247 + (undef, $account, $session) = require_auth($c, audience => TOKEN_AUD_REFRESH); 248 + 1; 249 + }; 250 + if (!$ok) { 251 + my $err = $@; 252 + if (ref($err) eq 'HASH' && ($err->{error} // q()) eq 'ExpiredToken') { 253 + xrpc_error(400, 'ExpiredToken', $err->{message}); 254 + } 255 + die $err; 256 + } 246 257 assert_login_allowed($c, $account, allow_deactivated => 1); 247 258 my $rotated = $c->store->rotate_session($session->{id}); 248 - xrpc_error(401, 'ExpiredToken', 'Refresh session has already been revoked') unless $rotated; 259 + xrpc_error(400, 'ExpiredToken', 'Refresh session has already been revoked') unless $rotated; 249 260 return _session_response($c, $account, $rotated); 250 261 }); 251 262 ··· 311 322 my (undef, $account) = require_auth( 312 323 $c, 313 324 audience => TOKEN_AUD_ACCESS, 314 - required_scope => 'full', 325 + required_scope => 'standard', 315 326 disallow_oauth => 1, 316 327 ); 317 328 my $rows = $c->store->list_app_passwords_by_did($account->{did}); ··· 626 637 lxm => $rpc_lxm, 627 638 ); 628 639 } elsif (length($normalized_lxm) && _service_auth_method_requires_privileged_access($normalized_lxm) && !_scope_allows($scope, 'privileged')) { 629 - xrpc_error(400, 'InvalidToken', 'Bad token scope'); 640 + xrpc_error(400, 'InvalidRequest', 'Bad token scope'); 630 641 } 631 642 my $requested_exp = $c->param('exp'); 632 643 my $now = time; ··· 1044 1055 sub _canonical_access_scope ($scope = undef) { 1045 1056 return TOKEN_AUD_ACCESS unless defined $scope && length $scope; 1046 1057 return TOKEN_AUD_ACCESS if $scope eq 'atproto'; 1058 + return 'com.atproto.appPass' if $scope eq 'app_password'; 1059 + return 'com.atproto.appPassPrivileged' if $scope eq 'app_password_privileged'; 1047 1060 return $scope; 1048 1061 } 1049 1062 ··· 1066 1079 return 1 if !defined($required_scope) || !length($required_scope); 1067 1080 return $scope eq TOKEN_AUD_ACCESS 1068 1081 if $required_scope eq 'full'; 1069 - return $scope eq TOKEN_AUD_ACCESS || $scope eq 'app_password_privileged' 1082 + return $scope eq TOKEN_AUD_ACCESS || $scope eq 'com.atproto.appPassPrivileged' 1070 1083 if $required_scope eq 'privileged'; 1071 - return $scope eq TOKEN_AUD_ACCESS || $scope eq 'app_password' || $scope eq 'app_password_privileged' 1084 + return $scope eq TOKEN_AUD_ACCESS || $scope eq 'com.atproto.appPass' || $scope eq 'com.atproto.appPassPrivileged' 1072 1085 if $required_scope eq 'standard'; 1073 1086 return 0; 1074 1087 }
+162
script/differential-validate
··· 682 682 'refreshSession grace and successor semantics match the official reference PDS', 683 683 ); 684 684 685 + note('Comparing app password semantics'); 686 + for my $name (sort keys %server) { 687 + my $create_phone = post_json( 688 + $server{$name}{origin}, 689 + 'com.atproto.server.createAppPassword', 690 + { name => 'phone' }, 691 + auth_header($server{$name}{access}), 692 + ); 693 + check($create_phone->is_success, "$name createAppPassword succeeds for a standard app password"); 694 + 695 + my $create_desktop = post_json( 696 + $server{$name}{origin}, 697 + 'com.atproto.server.createAppPassword', 698 + { 699 + name => 'desktop', 700 + privileged => true, 701 + }, 702 + auth_header($server{$name}{access}), 703 + ); 704 + check($create_desktop->is_success, "$name createAppPassword succeeds for a privileged app password"); 705 + next unless $create_phone->is_success && $create_desktop->is_success; 706 + 707 + my $phone_json = $create_phone->json || {}; 708 + my $desktop_json = $create_desktop->json || {}; 709 + 710 + my $list_before = get_json( 711 + $server{$name}{origin}, 712 + 'com.atproto.server.listAppPasswords', 713 + undef, 714 + auth_header($server{$name}{access}), 715 + ); 716 + check($list_before->is_success, "$name listAppPasswords succeeds after creation"); 717 + 718 + my $phone_session = post_json( 719 + $server{$name}{origin}, 720 + 'com.atproto.server.createSession', 721 + { 722 + identifier => $server{$name}{handle}, 723 + password => $phone_json->{password}, 724 + }, 725 + ); 726 + check($phone_session->is_success, "$name createSession succeeds with the standard app password"); 727 + 728 + my $desktop_session = post_json( 729 + $server{$name}{origin}, 730 + 'com.atproto.server.createSession', 731 + { 732 + identifier => $server{$name}{handle}, 733 + password => $desktop_json->{password}, 734 + }, 735 + ); 736 + check($desktop_session->is_success, "$name createSession succeeds with the privileged app password"); 737 + 738 + my $phone_access = ($phone_session->json || {})->{accessJwt}; 739 + my $phone_refresh = ($phone_session->json || {})->{refreshJwt}; 740 + my $desktop_access = ($desktop_session->json || {})->{accessJwt}; 741 + 742 + my $nested_create = post_json( 743 + $server{$name}{origin}, 744 + 'com.atproto.server.createAppPassword', 745 + { name => 'nested' }, 746 + auth_header($phone_access), 747 + ); 748 + my $phone_list = get_json( 749 + $server{$name}{origin}, 750 + 'com.atproto.server.listAppPasswords', 751 + undef, 752 + auth_header($phone_access), 753 + ); 754 + my $phone_service_auth = get_form( 755 + $server{$name}{origin}, 756 + 'com.atproto.server.getServiceAuth', 757 + { 758 + aud => 'did:web:api.bsky.app', 759 + lxm => 'com.atproto.server.createaccount', 760 + }, 761 + auth_header($phone_access), 762 + ); 763 + my $desktop_service_auth = get_form( 764 + $server{$name}{origin}, 765 + 'com.atproto.server.getServiceAuth', 766 + { 767 + aud => 'did:web:api.bsky.app', 768 + lxm => 'com.atproto.server.createaccount', 769 + }, 770 + auth_header($desktop_access), 771 + ); 772 + 773 + my $revoke_phone = post_json( 774 + $server{$name}{origin}, 775 + 'com.atproto.server.revokeAppPassword', 776 + { name => 'phone' }, 777 + auth_header($server{$name}{access}), 778 + ); 779 + check($revoke_phone->is_success, "$name revokeAppPassword succeeds for the standard app password"); 780 + 781 + my $revoked_refresh = post_empty( 782 + $server{$name}{origin}, 783 + 'com.atproto.server.refreshSession', 784 + auth_header($phone_refresh), 785 + ); 786 + my $list_after = get_json( 787 + $server{$name}{origin}, 788 + 'com.atproto.server.listAppPasswords', 789 + undef, 790 + auth_header($server{$name}{access}), 791 + ); 792 + 793 + my %before_passwords = map { 794 + ( 795 + ($_->{name} // q()) => { 796 + privileged => $_->{privileged} ? 1 : 0, 797 + } 798 + ) 799 + } @{ ($list_before->json || {})->{passwords} || [] }; 800 + 801 + my %after_passwords = map { 802 + ( 803 + ($_->{name} // q()) => { 804 + privileged => $_->{privileged} ? 1 : 0, 805 + } 806 + ) 807 + } @{ ($list_after->json || {})->{passwords} || [] }; 808 + 809 + $server{$name}{app_passwords} = { 810 + phone_create => { 811 + name => $phone_json->{name} // q(), 812 + has_password => length($phone_json->{password} // q()) ? 1 : 0, 813 + privileged => $phone_json->{privileged} ? 1 : 0, 814 + }, 815 + desktop_create => { 816 + name => $desktop_json->{name} // q(), 817 + has_password => length($desktop_json->{password} // q()) ? 1 : 0, 818 + privileged => $desktop_json->{privileged} ? 1 : 0, 819 + }, 820 + list_before => { 821 + passwords => \%before_passwords, 822 + }, 823 + phone_session_scope => jwt_claims($phone_access)->{scope} // q(), 824 + desktop_session_scope => jwt_claims($desktop_access)->{scope} // q(), 825 + nested_create_error => normalize_xrpc_error($nested_create), 826 + phone_list_error => normalize_xrpc_error($phone_list), 827 + phone_service_auth => normalize_xrpc_error($phone_service_auth), 828 + desktop_service_auth => { 829 + status => $desktop_service_auth->code, 830 + has_token => length((($desktop_service_auth->json || {})->{token}) // q()) ? 1 : 0, 831 + }, 832 + revoked_refresh_error => normalize_xrpc_error($revoked_refresh), 833 + list_after => { 834 + passwords => \%after_passwords, 835 + }, 836 + }; 837 + } 838 + 839 + if (!same_hash($server{reference}{app_passwords}, $server{perlsky}{app_passwords})) { 840 + note('reference app passwords: ' . encode_json($server{reference}{app_passwords})); 841 + note('perlsky app passwords: ' . encode_json($server{perlsky}{app_passwords})); 842 + fail_check('app password lifecycle semantics match the official reference PDS'); 843 + } else { 844 + pass('app password lifecycle semantics match the official reference PDS'); 845 + } 846 + 685 847 if ($diff_account_did_method eq 'did:plc') { 686 848 note('Comparing PLC identity semantics'); 687 849 for my $name (sort keys %server) {
+2 -2
t/auth-jwt.t
··· 102 102 id => 'sess-app', 103 103 did => $account->{did}, 104 104 kind => 'app_password', 105 - scope => 'app_password', 105 + scope => 'com.atproto.appPass', 106 106 expires_at => 1_900_000_000, 107 107 ); 108 108 my $full_session = $store->create_session( ··· 118 118 iss => 'did:web:example.test', 119 119 sub => $account->{did}, 120 120 aud => TOKEN_AUD_ACCESS, 121 - scope => 'app_password', 121 + scope => 'com.atproto.appPass', 122 122 typ => TOKEN_AUD_ACCESS, 123 123 jti => $app_session->{id}, 124 124 exp => 1_900_000_000,
+11 -8
t/server-auth.t
··· 126 126 my $app_session = $t->tx->res->json; 127 127 my (undef, $app_claims_b64, undef) = split /\./, $app_session->{accessJwt}, 3; 128 128 my $app_claims = decode_json(_b64url_decode($app_claims_b64)); 129 - is($app_claims->{scope}, 'app_password', 'app password login issues an app password-scoped access token'); 129 + is($app_claims->{scope}, 'com.atproto.appPass', 'app password login issues an app password-scoped access token'); 130 130 131 131 $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 132 132 identifier => 'alice.localhost', ··· 137 137 my $privileged_app_session = $t->tx->res->json; 138 138 is( 139 139 _jwt_claims($privileged_app_session->{accessJwt})->{scope}, 140 - 'app_password_privileged', 140 + 'com.atproto.appPassPrivileged', 141 141 'privileged app password login preserves privileged scope', 142 142 ); 143 143 ··· 145 145 ->status_is(200) 146 146 ->json_is('/did' => $did); 147 147 148 + $t->get_ok('/xrpc/com.atproto.server.listAppPasswords' => { Authorization => "Bearer $app_session->{accessJwt}" }) 149 + ->status_is(200); 150 + 151 + my %app_scoped_password = map { $_->{name} => $_ } @{ $t->tx->res->json->{passwords} || [] }; 152 + is($app_scoped_password{phone}{privileged}, 0, 'app-password sessions can list standard app passwords'); 153 + is($app_scoped_password{desktop}{privileged}, 1, 'app-password sessions can list privileged app passwords'); 154 + 148 155 $t->get_ok('/xrpc/com.atproto.server.getServiceAuth?aud=did:web:api.bsky.app&lxm=com.atproto.server.createaccount' => { 149 156 Authorization => "Bearer $app_session->{accessJwt}", 150 157 })->status_is(400) 151 - ->json_is('/error' => 'InvalidToken'); 158 + ->json_is('/error' => 'InvalidRequest'); 152 159 153 160 $t->get_ok('/xrpc/com.atproto.server.getServiceAuth?aud=did:web:api.bsky.app&lxm=com.atproto.server.createaccount' => { 154 161 Authorization => "Bearer $privileged_app_session->{accessJwt}", ··· 160 167 })->status_is(400) 161 168 ->json_is('/error' => 'InvalidToken'); 162 169 163 - $t->get_ok('/xrpc/com.atproto.server.listAppPasswords' => { Authorization => "Bearer $app_session->{accessJwt}" }) 164 - ->status_is(400) 165 - ->json_is('/error' => 'InvalidToken'); 166 - 167 170 $t->post_ok('/xrpc/com.atproto.server.revokeAppPassword' => { Authorization => "Bearer $app_session->{accessJwt}" } => json => { 168 171 name => 'desktop', 169 172 })->status_is(400) ··· 178 181 })->status_is(200); 179 182 180 183 $t->post_ok('/xrpc/com.atproto.server.refreshSession' => { Authorization => "Bearer $app_session->{refreshJwt}" } => json => {}) 181 - ->status_is(401) 184 + ->status_is(400) 182 185 ->json_is('/error' => 'ExpiredToken') 183 186 ->json_is('/message' => 'Token session has already been revoked'); 184 187