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 account recovery and PLC OAuth semantics

alice d0952a7c 16c510b5

+258 -21
+13 -2
lib/ATProto/PDS/API/Misc.pm
··· 11 11 use ATProto::PDS::API::Helpers qw(find_account issue_account_action_token require_admin subject_key); 12 12 use ATProto::PDS::API::Server qw(require_auth); 13 13 use ATProto::PDS::API::Util qw(flatten_params iso8601 pump_event_subscription subscription_start_seq xrpc_error); 14 + use ATProto::PDS::Auth::OAuth qw(oauth_scope_has_atproto); 14 15 use ATProto::PDS::Auth::Password qw(hash_password random_hex); 15 16 use ATProto::PDS::Constants qw( 16 17 ACTION_TOKEN_PLC_OPERATION ··· 62 63 }); 63 64 64 65 $registry->register('com.atproto.identity.requestPlcOperationSignature', sub ($c, $endpoint) { 65 - my (undef, $account) = require_auth( 66 + my ($claims, $account) = require_auth( 66 67 $c, 67 68 audience => TOKEN_AUD_ACCESS, 68 69 required_permission => { ··· 70 71 attr => '*', 71 72 }, 72 73 ); 74 + _assert_full_non_oauth_access($claims); 73 75 xrpc_error(400, 'InvalidRequest', 'account does not have an email address') 74 76 unless defined($account->{email}) && length($account->{email}); 75 77 issue_account_action_token( ··· 83 85 }); 84 86 85 87 $registry->register('com.atproto.identity.signPlcOperation', sub ($c, $endpoint) { 86 - my (undef, $account) = require_auth( 88 + my ($claims, $account) = require_auth( 87 89 $c, 88 90 audience => TOKEN_AUD_ACCESS, 89 91 required_permission => { ··· 91 93 attr => '*', 92 94 }, 93 95 ); 96 + _assert_full_non_oauth_access($claims); 94 97 xrpc_error(400, 'InvalidRequest', 'PLC operations are only supported for did:plc accounts') 95 98 unless is_plc_did($account->{did}); 96 99 my $body = $c->req->json || {}; ··· 347 350 }, 348 351 ); 349 352 return; 353 + } 354 + 355 + sub _assert_full_non_oauth_access ($claims) { 356 + return if ($claims->{typ} // q()) eq 'oauth_access'; 357 + return if oauth_scope_has_atproto($claims->{scope} // q()); 358 + xrpc_error(400, 'InvalidToken', 'Bad token scope') 359 + unless (($claims->{scope} // TOKEN_AUD_ACCESS) eq TOKEN_AUD_ACCESS); 360 + return 1; 350 361 } 351 362 352 363 sub _label_page ($page) {
+15 -17
lib/ATProto/PDS/API/Server.pm
··· 189 189 my $body = $c->req->json || {}; 190 190 my $account = find_account($c, $body->{identifier} // q()); 191 191 xrpc_error(401, 'AuthRequired', 'Invalid identifier or password') unless $account; 192 + xrpc_error(401, 'AuthRequired', 'Invalid identifier or password') 193 + if defined $account->{deleted_at}; 192 194 my $authn = verify_login_password($c, $account, $body->{password} // q()); 193 195 xrpc_error(401, 'AuthRequired', 'Invalid identifier or password') unless $authn; 194 196 if (($authn->{kind} // q()) eq 'app_password' && is_repo_takedown($c, $account->{did})) { ··· 383 385 $registry->register('com.atproto.server.requestPasswordReset', sub ($c, $endpoint) { 384 386 my $body = $c->req->json || {}; 385 387 my $account = $c->store->get_account_by_email($body->{email} // q()); 386 - if ($account) { 387 - issue_account_action_token( 388 - $c, 389 - $account, 390 - purpose => ACTION_TOKEN_PASSWORD_RESET, 391 - subject => 'perlsky password reset', 392 - content => sub ($token) { "Use token $token->{token} to reset your password." }, 393 - ); 394 - } 388 + xrpc_error(400, 'InvalidRequest', 'account does not have an email address') 389 + unless $account && !defined($account->{deleted_at}) && defined($account->{email}) && length($account->{email}); 390 + issue_account_action_token( 391 + $c, 392 + $account, 393 + purpose => ACTION_TOKEN_PASSWORD_RESET, 394 + subject => 'perlsky password reset', 395 + content => sub ($token) { "Use token $token->{token} to reset your password." }, 396 + ); 395 397 return {}; 396 398 }); 397 399 ··· 541 543 }); 542 544 543 545 $registry->register('com.atproto.server.deleteAccount', sub ($c, $endpoint) { 544 - my ($claims, $account) = require_auth( 545 - $c, 546 - audience => TOKEN_AUD_ACCESS, 547 - required_scope => 'full', 548 - disallow_oauth => 1, 549 - ); 550 546 my $body = $c->req->json || {}; 551 - xrpc_error(401, 'AuthRequired', 'Token is not authorized for that repo') 552 - unless ($claims->{sub} // q()) eq ($body->{did} // q()) && ($account->{did} // q()) eq ($body->{did} // q()); 547 + my $did = $body->{did} // q(); 548 + my $account = $c->store->get_account_by_did($did); 549 + xrpc_error(400, 'InvalidRequest', 'account not found') 550 + unless $account && !defined($account->{deleted_at}); 553 551 xrpc_error(401, 'AuthRequired', 'Invalid identifier or password') 554 552 unless verify_account_password($c, $account, $body->{password} // q()); 555 553 my $token = _require_action_token($c,
+104
t/delete-account.t
··· 1 + use v5.34; 2 + use warnings; 3 + 4 + use Config (); 5 + use File::Spec; 6 + use File::Temp qw(tempdir); 7 + use FindBin qw($Bin); 8 + use Test::More; 9 + 10 + BEGIN { 11 + require lib; 12 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 13 + lib->import( 14 + File::Spec->catdir($root, 'lib'), 15 + File::Spec->catdir($root, 'local', 'lib', 'perl5'), 16 + File::Spec->catdir($root, 'local', 'lib', 'perl5', $Config::Config{archname}), 17 + ); 18 + } 19 + 20 + use Test::Mojo; 21 + use ATProto::PDS; 22 + 23 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 24 + my $tmp = tempdir(CLEANUP => 1); 25 + 26 + my $app = ATProto::PDS->new( 27 + project_root => $root, 28 + settings => { 29 + base_url => 'http://127.0.0.1:7755', 30 + service_handle_domain => 'example.test', 31 + service_did_method => 'did:web', 32 + jwt_secret => 'delete-account-secret', 33 + testing_auto_confirm_email => 1, 34 + data_dir => $tmp, 35 + db_path => File::Spec->catfile($tmp, 'perlsky.sqlite'), 36 + }, 37 + ); 38 + 39 + my $t = Test::Mojo->new($app); 40 + 41 + $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 42 + handle => 'alice.example.test', 43 + email => 'alice@example.test', 44 + password => 'hunter22', 45 + })->status_is(200); 46 + 47 + my $created = $t->tx->res->json; 48 + my $did = $created->{did}; 49 + my $access = $created->{accessJwt}; 50 + 51 + $t->post_ok('/xrpc/com.atproto.server.requestAccountDelete' => { 52 + Authorization => "Bearer $access", 53 + } => json => {})->status_is(200) 54 + ->json_is({}); 55 + 56 + my $token = $app->store->latest_action_token( 57 + did => $did, 58 + purpose => 'account_delete', 59 + ); 60 + ok($token && $token->{token}, 'requestAccountDelete issues an account delete token'); 61 + 62 + $t->post_ok('/xrpc/com.atproto.server.deleteAccount' => json => { 63 + did => $did, 64 + password => 'wrong-password', 65 + token => $token->{token}, 66 + })->status_is(401) 67 + ->json_is('/error' => 'AuthRequired'); 68 + 69 + $t->post_ok('/xrpc/com.atproto.server.deleteAccount' => json => { 70 + did => 'did:web:example.test:users:missing', 71 + password => 'hunter22', 72 + token => $token->{token}, 73 + })->status_is(400) 74 + ->json_is('/error' => 'InvalidRequest') 75 + ->json_is('/message' => 'account not found'); 76 + 77 + $t->post_ok('/xrpc/com.atproto.server.deleteAccount' => json => { 78 + did => $did, 79 + password => 'hunter22', 80 + token => $token->{token}, 81 + })->status_is(200) 82 + ->json_is({}); 83 + 84 + ok(defined $app->store->get_account_by_did($did)->{deleted_at}, 'deleteAccount marks the account deleted'); 85 + 86 + $t->get_ok('/xrpc/com.atproto.server.getSession' => { 87 + Authorization => "Bearer $access", 88 + })->status_is(401); 89 + 90 + $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 91 + identifier => 'alice.example.test', 92 + password => 'hunter22', 93 + })->status_is(401) 94 + ->json_is('/error' => 'AuthRequired'); 95 + 96 + $t->post_ok('/xrpc/com.atproto.server.deleteAccount' => json => { 97 + did => $did, 98 + password => 'hunter22', 99 + token => $token->{token}, 100 + })->status_is(400) 101 + ->json_is('/error' => 'InvalidRequest') 102 + ->json_is('/message' => 'account not found'); 103 + 104 + done_testing;
+45 -2
t/oauth-permissions.t
··· 83 83 return undef; 84 84 }; 85 85 86 - my $t = Test::Mojo->new(ATProto::PDS->new( 86 + my $app = ATProto::PDS->new( 87 87 project_root => $root, 88 88 settings => $config, 89 - )); 89 + ); 90 + my $t = Test::Mojo->new($app); 90 91 91 92 $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 92 93 handle => 'alice', ··· 117 118 $t->post_ok('/xrpc/com.atproto.server.requestEmailConfirmation' => _oauth_headers($email_manage->{access_token}, 'POST', $config->{base_url} . '/xrpc/com.atproto.server.requestEmailConfirmation') => json => {}) 118 119 ->status_is(200) 119 120 ->json_is({}); 121 + $t->post_ok('/xrpc/com.atproto.server.requestEmailUpdate' => _oauth_headers($email_manage->{access_token}, 'POST', $config->{base_url} . '/xrpc/com.atproto.server.requestEmailUpdate') => json => {}) 122 + ->status_is(200) 123 + ->json_is('/tokenRequired' => JSON::PP::true); 120 124 121 125 $t->post_ok('/xrpc/com.atproto.repo.createRecord' => _oauth_headers($atproto_only->{access_token}, 'POST', $config->{base_url} . '/xrpc/com.atproto.repo.createRecord') => json => { 122 126 repo => $did, ··· 235 239 $config->{base_url} . '/xrpc/com.atproto.identity.updateHandle', 236 240 ) => json => { handle => 'alice-renamed' })->status_is(200) 237 241 ->json_is({}); 242 + 243 + $t->post_ok('/xrpc/com.atproto.identity.requestPlcOperationSignature' => _oauth_headers( 244 + $identity_handle->{access_token}, 245 + 'POST', 246 + $config->{base_url} . '/xrpc/com.atproto.identity.requestPlcOperationSignature', 247 + ) => json => {})->status_is(403) 248 + ->json_like('/message' => qr/identity:\*/); 249 + 250 + my $identity_all = _oauth_tokens_for_scope($t, $did, 'atproto identity:*'); 251 + $t->post_ok('/xrpc/com.atproto.identity.requestPlcOperationSignature' => _oauth_headers( 252 + $identity_all->{access_token}, 253 + 'POST', 254 + $config->{base_url} . '/xrpc/com.atproto.identity.requestPlcOperationSignature', 255 + ) => json => {})->status_is(200) 256 + ->json_is({}); 257 + 258 + my $plc_token = $app->store->latest_action_token( 259 + did => $did, 260 + purpose => 'plc_operation', 261 + ); 262 + ok($plc_token && $plc_token->{token}, 'identity:* oauth token can request a PLC operation token'); 263 + 264 + $t->post_ok('/xrpc/com.atproto.identity.signPlcOperation' => _oauth_headers( 265 + $identity_handle->{access_token}, 266 + 'POST', 267 + $config->{base_url} . '/xrpc/com.atproto.identity.signPlcOperation', 268 + ) => json => { 269 + token => $plc_token->{token}, 270 + })->status_is(403) 271 + ->json_like('/message' => qr/identity:\*/); 272 + 273 + $t->post_ok('/xrpc/com.atproto.identity.signPlcOperation' => _oauth_headers( 274 + $identity_all->{access_token}, 275 + 'POST', 276 + $config->{base_url} . '/xrpc/com.atproto.identity.signPlcOperation', 277 + ) => json => { 278 + token => $plc_token->{token}, 279 + })->status_is(400) 280 + ->json_is('/error' => 'InvalidRequest'); 238 281 } 239 282 240 283 done_testing;
+81
t/password-reset.t
··· 1 + use v5.34; 2 + use warnings; 3 + 4 + use Config (); 5 + use File::Spec; 6 + use File::Temp qw(tempdir); 7 + use FindBin qw($Bin); 8 + use Test::More; 9 + 10 + BEGIN { 11 + require lib; 12 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 13 + lib->import( 14 + File::Spec->catdir($root, 'lib'), 15 + File::Spec->catdir($root, 'local', 'lib', 'perl5'), 16 + File::Spec->catdir($root, 'local', 'lib', 'perl5', $Config::Config{archname}), 17 + ); 18 + } 19 + 20 + use Test::Mojo; 21 + use ATProto::PDS; 22 + 23 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 24 + my $tmp = tempdir(CLEANUP => 1); 25 + 26 + my $app = ATProto::PDS->new( 27 + project_root => $root, 28 + settings => { 29 + base_url => 'http://127.0.0.1:7755', 30 + service_handle_domain => 'example.test', 31 + service_did_method => 'did:web', 32 + jwt_secret => 'password-reset-secret', 33 + testing_auto_confirm_email => 1, 34 + data_dir => $tmp, 35 + db_path => File::Spec->catfile($tmp, 'perlsky.sqlite'), 36 + }, 37 + ); 38 + 39 + my $t = Test::Mojo->new($app); 40 + 41 + $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 42 + handle => 'alice.example.test', 43 + email => 'alice@example.test', 44 + password => 'hunter22', 45 + })->status_is(200); 46 + 47 + $t->post_ok('/xrpc/com.atproto.server.requestPasswordReset' => json => { 48 + email => 'missing@example.test', 49 + })->status_is(400) 50 + ->json_is('/error' => 'InvalidRequest') 51 + ->json_is('/message' => 'account does not have an email address'); 52 + 53 + $t->post_ok('/xrpc/com.atproto.server.requestPasswordReset' => json => { 54 + email => 'alice@example.test', 55 + })->status_is(200) 56 + ->json_is({}); 57 + 58 + my $token = $app->store->latest_action_token( 59 + purpose => 'password_reset', 60 + ); 61 + ok($token && $token->{token}, 'requestPasswordReset issues a password reset token'); 62 + 63 + $t->post_ok('/xrpc/com.atproto.server.resetPassword' => json => { 64 + token => $token->{token}, 65 + password => 'new-hunter22', 66 + })->status_is(200) 67 + ->json_is({}); 68 + 69 + $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 70 + identifier => 'alice.example.test', 71 + password => 'hunter22', 72 + })->status_is(401) 73 + ->json_is('/error' => 'AuthRequired'); 74 + 75 + $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 76 + identifier => 'alice.example.test', 77 + password => 'new-hunter22', 78 + })->status_is(200) 79 + ->json_has('/accessJwt'); 80 + 81 + done_testing;