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.

Harden uncovered admin and PLC conformance edges

alice 99274dc6 ae0fe4ef

+367 -29
+40 -10
lib/ATProto/PDS/API/Admin.pm
··· 11 11 use ATProto::PDS::API::Helpers qw(account_view find_account invite_code_view require_admin subject_key); 12 12 use ATProto::PDS::API::Util qw(flatten_params xrpc_error); 13 13 use ATProto::PDS::Auth::Password qw(hash_password); 14 + use ATProto::PDS::Constants qw(EVENT_TYPE_IDENTITY); 14 15 use ATProto::PDS::Crypto::Secp256k1 qw(signing_did_to_public_key_multibase); 15 - use ATProto::PDS::Identity qw(account_did_doc normalize_handle service_did); 16 + use ATProto::PDS::Identity qw(account_did_doc normalize_handle resolve_handle_to_did service_did); 16 17 use ATProto::PDS::Moderation qw(current_record_subject current_subject_status parse_at_uri); 17 18 18 19 our @EXPORT_OK = qw(register_admin_handlers); ··· 126 127 my $body = $c->req->json || {}; 127 128 my $account = $c->store->get_account_by_did($body->{did} // q()); 128 129 xrpc_error(404, 'AccountNotFound', 'Account was not found') unless $account; 129 - my $handle = normalize_handle($body->{handle}, $c->config_value('service_handle_domain', 'localhost')); 130 + my $domain = $c->config_value('service_handle_domain', 'localhost'); 131 + my $handle = normalize_handle($body->{handle}, $domain); 132 + $handle = normalize_handle($body->{handle}, undef, { no_append => 1 }) 133 + unless defined $handle; 130 134 xrpc_error(400, 'InvalidHandle', 'Requested handle is invalid') unless defined $handle; 135 + my $service_handle = normalize_handle($handle, $domain, { no_append => 1 }); 136 + if (!defined $service_handle) { 137 + my $resolved_did = resolve_handle_to_did($c->app->settings, $handle); 138 + xrpc_error(400, 'InvalidRequest', 'External handle did not resolve to DID') 139 + unless defined $resolved_did && lc($resolved_did) eq lc($account->{did}); 140 + } 131 141 my $existing = $c->store->get_account_by_handle($handle); 132 142 xrpc_error(400, 'HandleNotAvailable', 'That handle is already registered') 133 143 if $existing && ($existing->{did} // q()) ne $account->{did}; 134 - $c->store->update_account( 144 + my $updated = $c->store->update_account( 135 145 $account->{did}, 136 146 handle => $handle, 137 147 did_doc => account_did_doc($c->app->settings, { %$account, handle => $handle }), 138 148 ); 149 + _append_identity_event($c, $updated); 139 150 return {}; 140 151 }); 141 152 ··· 147 158 my $account = $c->store->get_account_by_did($body->{did} // q()); 148 159 xrpc_error(404, 'AccountNotFound', 'Account was not found') unless $account; 149 160 my $password_record = hash_password($body->{password}); 150 - $c->store->update_account( 151 - $account->{did}, 152 - password_hash => $password_record->{hash}, 153 - password_salt => $password_record->{salt}, 154 - ); 161 + $c->store->txn(sub ($dbh) { 162 + $c->store->update_account( 163 + $account->{did}, 164 + password_hash => $password_record->{hash}, 165 + password_salt => $password_record->{salt}, 166 + ); 167 + $c->store->revoke_sessions_by_did($account->{did}); 168 + }); 155 169 return {}; 156 170 }); 157 171 ··· 229 243 my $signing_key = $body->{signingKey} // q(); 230 244 xrpc_error(400, 'InvalidRequest', 'signingKey must be a did:key') 231 245 unless $signing_key =~ /\Adid:key:/; 232 - my $multibase = signing_did_to_public_key_multibase($signing_key); 246 + my $multibase = eval { signing_did_to_public_key_multibase($signing_key) }; 247 + xrpc_error(400, 'InvalidRequest', 'signingKey must be a valid secp256k1 did:key') 248 + if $@ || !defined($multibase) || !length($multibase); 233 249 my $updated = { 234 250 %$account, 235 251 public_key_multibase => $multibase, 236 252 signing_key_did => $signing_key, 237 253 }; 238 - $c->store->update_account( 254 + my $stored = $c->store->update_account( 239 255 $account->{did}, 240 256 public_key_multibase => $multibase, 241 257 signing_key_did => $signing_key, 242 258 did_doc => account_did_doc($c->app->settings, $updated), 243 259 ); 260 + _append_identity_event($c, $stored); 244 261 return {}; 245 262 }); 263 + } 264 + 265 + sub _append_identity_event ($c, $account) { 266 + $c->append_event( 267 + did => $account->{did}, 268 + type => EVENT_TYPE_IDENTITY, 269 + rev => $account->{repo_rev}, 270 + payload => { 271 + did => $account->{did}, 272 + handle => $account->{handle}, 273 + }, 274 + ); 275 + return; 246 276 } 247 277 248 278 sub _subject_from_params ($c) {
+27 -8
lib/ATProto/PDS/API/Misc.pm
··· 135 135 unless is_plc_did($account->{did}); 136 136 my $body = $c->req->json || {}; 137 137 my $operation = $body->{operation} || {}; 138 + xrpc_error(400, 'InvalidRequest', 'Invalid operation') 139 + unless _valid_plc_operation($operation); 138 140 my $rotation_did = ATProto::PDS::PLC::plc_rotation_did($c->app->settings); 139 141 xrpc_error(400, 'InvalidRequest', q{Rotation keys do not include server's rotation key}) 140 142 unless grep { ($_ // q()) eq $rotation_did } @{ $operation->{rotationKeys} || [] }; ··· 180 182 if $existing && ($existing->{did} // q()) ne $account->{did}; 181 183 xrpc_error(400, 'HandleNotAvailable', 'That handle is reserved') 182 184 if $c->store->get_reserved_handle($handle); 183 - my $did_doc = is_plc_did($account->{did}) 184 - ? plc_update_handle($c->app->settings, $account, $handle) 185 - : account_did_doc($c->app->settings, { %$account, handle => $handle }); 186 - my $updated = $c->store->update_account( 187 - $account->{did}, 188 - handle => $handle, 189 - did_doc => $did_doc, 190 - ); 185 + my %changes = (handle => $handle); 186 + if (is_plc_did($account->{did})) { 187 + plc_update_handle($c->app->settings, $account, $handle); 188 + } else { 189 + $changes{did_doc} = account_did_doc($c->app->settings, { %$account, handle => $handle }); 190 + } 191 + my $updated = $c->store->update_account($account->{did}, %changes); 191 192 _append_identity_event($c, $updated); 192 193 return {}; 193 194 }); ··· 308 309 }); 309 310 310 311 $registry->register('com.atproto.temp.revokeAccountCredentials', sub ($c, $endpoint) { 312 + require_admin($c); 311 313 my $body = $c->req->json || {}; 312 314 my $account = find_account($c, $body->{account} // q()); 313 315 xrpc_error(404, 'AccountNotFound', 'Account was not found') unless $account; ··· 357 359 return if oauth_scope_has_atproto($claims->{scope} // q()); 358 360 xrpc_error(400, 'InvalidToken', 'Bad token scope') 359 361 unless (($claims->{scope} // TOKEN_AUD_ACCESS) eq TOKEN_AUD_ACCESS); 362 + return 1; 363 + } 364 + 365 + sub _valid_plc_operation ($operation) { 366 + return 0 unless ref($operation) eq 'HASH'; 367 + return 0 unless ($operation->{type} // q()) eq 'plc_operation'; 368 + return 0 unless ref($operation->{rotationKeys}) eq 'ARRAY' && @{ $operation->{rotationKeys} }; 369 + return 0 unless ref($operation->{alsoKnownAs}) eq 'ARRAY'; 370 + return 0 unless ref($operation->{verificationMethods}) eq 'HASH' && keys %{ $operation->{verificationMethods} }; 371 + return 0 unless ref($operation->{services}) eq 'HASH' && ref($operation->{services}{atproto_pds}) eq 'HASH'; 372 + return 0 unless defined($operation->{sig}) && !ref($operation->{sig}) && length($operation->{sig}); 373 + return 0 if exists($operation->{prev}) && defined($operation->{prev}) && ref($operation->{prev}); 374 + return 0 if grep { !defined($_) || ref($_) || !length($_) } @{ $operation->{rotationKeys} }; 375 + return 0 if grep { !defined($_) || ref($_) || !length($_) } @{ $operation->{alsoKnownAs} }; 376 + for my $value (values %{ $operation->{verificationMethods} }) { 377 + return 0 if !defined($value) || ref($value) || !length($value); 378 + } 360 379 return 1; 361 380 } 362 381
+14 -2
lib/ATProto/PDS/API/Repo.pm
··· 20 20 use ATProto::PDS::Constants qw(TOKEN_AUD_ACCESS); 21 21 use ATProto::PDS::Identity qw(account_did_doc normalize_handle resolve_handle_to_did); 22 22 use ATProto::PDS::Moderation qw(assert_record_readable assert_repo_readable assert_repo_writable is_record_takedown parse_at_uri); 23 - use ATProto::PDS::PLC qw(is_plc_did refresh_plc_did_doc); 24 23 use ATProto::PDS::Repo::CID; 25 24 use ATProto::PDS::Repo::DagCbor qw(encode_dag_cbor); 26 25 ··· 408 407 return "at://$did/$collection/$rkey"; 409 408 } 410 409 410 + sub _describe_repo_did_doc ($c, $account) { 411 + return $account->{did_doc} if ref($account->{did_doc}) eq 'HASH'; 412 + return account_did_doc($c->app->settings, $account); 413 + } 414 + 415 + sub _describe_repo_handle_is_correct ($c, $account, $did_doc) { 416 + my $handle = normalize_handle($account->{handle}, undef, { no_append => 1 }); 417 + return 0 unless defined $handle; 418 + my $doc_handle = _did_doc_handle($did_doc); 419 + return defined($doc_handle) && lc($doc_handle) eq lc($handle) ? 1 : 0; 420 + } 421 + 411 422 sub _did_doc_handle ($did_doc) { 412 423 return undef unless ref($did_doc) eq 'HASH'; 413 424 for my $aka (@{ $did_doc->{alsoKnownAs} || [] }) { 414 425 next unless defined $aka && $aka =~ m{\Aat://(.+)\z}; 415 - return $1; 426 + my $handle = normalize_handle($1, undef, { no_append => 1 }); 427 + return $handle if defined $handle; 416 428 } 417 429 return undef; 418 430 }
+16 -4
lib/ATProto/PDS/Identity.pm
··· 110 110 111 111 my ($service) = grep { 112 112 ref($_) eq 'HASH' 113 - && (($_->{id} // q()) eq "$did#atproto_pds" || ($_->{type} // q()) eq 'AtprotoPersonalDataServer') 113 + && ( 114 + ($_->{id} // q()) eq "$did#atproto_pds" 115 + || ($_->{id} // q()) eq '#atproto_pds' 116 + || ($_->{type} // q()) eq 'AtprotoPersonalDataServer' 117 + ) 114 118 } @{ $doc->{service} || [] }; 115 119 return 0 unless $service; 116 120 return 0 unless ($service->{type} // q()) eq 'AtprotoPersonalDataServer'; ··· 120 124 return 1 unless length $expected_multibase; 121 125 122 126 my ($verification_method) = grep { 123 - ref($_) eq 'HASH' && (($_->{id} // q()) eq "$did#atproto") 127 + ref($_) eq 'HASH' 128 + && ( 129 + (($_->{id} // q()) eq "$did#atproto") 130 + || (($_->{id} // q()) eq '#atproto') 131 + || (($_->{publicKeyMultibase} // q()) eq $expected_multibase) 132 + ) 124 133 } @{ $doc->{verificationMethod} || [] }; 125 134 return 0 unless $verification_method; 126 135 return 0 unless ($verification_method->{publicKeyMultibase} // q()) eq $expected_multibase; 127 136 128 - my %assertion_methods = map { ($_ // q()) => 1 } @{ $doc->{assertionMethod} || [] }; 129 - return 0 unless $assertion_methods{"$did#atproto"}; 137 + my @assertion_methods = @{ $doc->{assertionMethod} || [] }; 138 + if (@assertion_methods) { 139 + my %assertion_methods = map { ($_ // q()) => 1 } @assertion_methods; 140 + return 0 unless $assertion_methods{"$did#atproto"} || $assertion_methods{'#atproto'}; 141 + } 130 142 131 143 return 1; 132 144 }
+17
script/differential-validate
··· 732 732 same_hash($server{reference}{missing_plc_token_error}, $server{perlsky}{missing_plc_token_error}), 733 733 'signPlcOperation matches the official reference PDS token requirement semantics', 734 734 ); 735 + 736 + note('Comparing PLC submitPlcOperation validation'); 737 + for my $name (sort keys %server) { 738 + my $res = post_json( 739 + $server{$name}{origin}, 740 + 'com.atproto.identity.submitPlcOperation', 741 + { operation => {} }, 742 + auth_header($server{$name}{access}), 743 + ); 744 + check(!$res->is_success, "$name submitPlcOperation rejects malformed operations"); 745 + $server{$name}{invalid_plc_operation_error} = normalize_xrpc_error($res); 746 + } 747 + 748 + check( 749 + same_hash($server{reference}{invalid_plc_operation_error}, $server{perlsky}{invalid_plc_operation_error}), 750 + 'submitPlcOperation matches the official reference PDS invalid-operation semantics', 751 + ); 735 752 } 736 753 737 754 my $record = {
+8 -5
t/plc-identity.t
··· 182 182 })->status_is(200) 183 183 ->json_is('/did', $did); 184 184 185 - $t->get_ok('/xrpc/com.atproto.identity.resolveDid' => form => { 186 - did => $did, 187 - })->status_is(200) 188 - ->json_is('/didDoc/alsoKnownAs/0', 'at://alice-renamed.test'); 189 - 190 185 $t->post_ok('/xrpc/com.atproto.identity.requestPlcOperationSignature' => { 191 186 Authorization => "Bearer $access", 192 187 })->status_is(200); ··· 233 228 ); 234 229 ok(length($signed->{sig} // q()) > 10, 'signed operation contains a signature'); 235 230 like($signed->{prev} // q(), qr/\Ab/, 'signed operation references the prior PLC op by CID'); 231 + 232 + $t->post_ok('/xrpc/com.atproto.identity.submitPlcOperation' => { 233 + Authorization => "Bearer $access", 234 + } => json => { 235 + operation => {}, 236 + })->status_is(400) 237 + ->json_is('/error', 'InvalidRequest') 238 + ->json_is('/message', 'Invalid operation'); 236 239 237 240 $t->post_ok('/xrpc/com.atproto.identity.submitPlcOperation' => { 238 241 Authorization => "Bearer $access",
+245
t/uncovered-endpoints.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 MIME::Base64 qw(encode_base64); 9 + use Test::More; 10 + 11 + BEGIN { 12 + require lib; 13 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 14 + lib->import( 15 + File::Spec->catdir($root, 'lib'), 16 + File::Spec->catdir($root, 'local', 'lib', 'perl5'), 17 + File::Spec->catdir($root, 'local', 'lib', 'perl5', $Config::Config{archname}), 18 + ); 19 + } 20 + 21 + use Test::Mojo; 22 + use ATProto::PDS; 23 + 24 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 25 + my $tmp = tempdir(CLEANUP => 1); 26 + 27 + my $app = ATProto::PDS->new( 28 + project_root => $root, 29 + settings => { 30 + base_url => 'http://127.0.0.1:7755', 31 + service_handle_domain => 'example.test', 32 + service_did_method => 'did:web', 33 + jwt_secret => 'uncovered-endpoints-secret', 34 + admin_password => 'admin-secret', 35 + self_service_invite_codes => 1, 36 + testing_auto_confirm_email => 1, 37 + data_dir => $tmp, 38 + db_path => File::Spec->catfile($tmp, 'perlsky.sqlite'), 39 + }, 40 + ); 41 + 42 + my $t = Test::Mojo->new($app); 43 + my $admin_auth = 'Basic ' . encode_base64('admin:admin-secret', q()); 44 + 45 + $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 46 + handle => 'alice.example.test', 47 + email => 'alice@example.test', 48 + password => 'hunter22', 49 + })->status_is(200); 50 + 51 + my $created = $t->tx->res->json; 52 + my $did = $created->{did}; 53 + my $access = $created->{accessJwt}; 54 + 55 + $t->post_ok('/xrpc/com.atproto.server.reserveSigningKey' => json => {}) 56 + ->status_is(200) 57 + ->json_like('/signingKey' => qr/\Adid:key:/); 58 + 59 + my $reserved_signing_key = $t->tx->res->json->{signingKey}; 60 + 61 + $t->post_ok('/xrpc/com.atproto.server.reserveSigningKey' => json => { 62 + did => 'did:plc:reserved-target', 63 + })->status_is(200) 64 + ->json_like('/signingKey' => qr/\Adid:key:/); 65 + 66 + my $reserved = $app->store->get_reserved_signing_key('did:plc:reserved-target'); 67 + ok($reserved && $reserved->{signing_key_did}, 'reserveSigningKey persists a reserved key for an explicit DID'); 68 + is($reserved->{signing_key_did}, $t->tx->res->json->{signingKey}, 'stored reserved key matches the returned signing key'); 69 + 70 + $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { 71 + Authorization => "Bearer $access", 72 + } => json => { 73 + repo => $did, 74 + collection => 'app.bsky.feed.post', 75 + rkey => 'describe-repo', 76 + record => { 77 + '$type' => 'app.bsky.feed.post', 78 + text => 'describe repo', 79 + createdAt => '2026-03-12T00:00:00Z', 80 + }, 81 + })->status_is(200) 82 + ->json_is('/uri' => "at://$did/app.bsky.feed.post/describe-repo"); 83 + 84 + $t->get_ok("/xrpc/com.atproto.repo.describeRepo?repo=$did") 85 + ->status_is(200) 86 + ->json_is('/did' => $did) 87 + ->json_is('/handle' => 'alice.example.test') 88 + ->json_is('/handleIsCorrect' => JSON::PP::true); 89 + 90 + ok( 91 + scalar(grep { $_ eq 'app.bsky.feed.post' } @{ $t->tx->res->json->{collections} || [] }), 92 + 'describeRepo lists created collections', 93 + ); 94 + 95 + my $account = $app->store->get_account_by_did($did); 96 + my $broken_doc = { 97 + %{ $account->{did_doc} || {} }, 98 + alsoKnownAs => ['at://wrong.example.test'], 99 + }; 100 + $app->store->update_account($did, did_doc => $broken_doc); 101 + 102 + $t->get_ok("/xrpc/com.atproto.repo.describeRepo?repo=$did") 103 + ->status_is(200) 104 + ->json_is('/handleIsCorrect' => JSON::PP::false) 105 + ->json_is('/didDoc/alsoKnownAs/0' => 'at://wrong.example.test'); 106 + 107 + my $before_admin_handle_seq = $app->store->latest_event_seq; 108 + { 109 + no warnings 'redefine'; 110 + local *ATProto::PDS::API::Admin::resolve_handle_to_did = sub { 111 + my ($config, $handle) = @_; 112 + return $handle eq 'alice.external.test' ? $did : undef; 113 + }; 114 + 115 + $t->post_ok('/xrpc/com.atproto.admin.updateAccountHandle' => { 116 + Authorization => $admin_auth, 117 + } => json => { 118 + did => $did, 119 + handle => 'alice.external.test', 120 + })->status_is(200) 121 + ->json_is({}); 122 + } 123 + 124 + my $handle_event = $app->store->list_events_from($before_admin_handle_seq + 1, limit => 1)->[0]; 125 + is($handle_event->{type}, 'identity', 'admin.updateAccountHandle appends an identity event'); 126 + is($handle_event->{payload}{handle}, 'alice.external.test', 'identity event carries the updated handle'); 127 + 128 + $t->post_ok('/xrpc/com.atproto.admin.updateAccountSigningKey' => { 129 + Authorization => $admin_auth, 130 + } => json => { 131 + did => $did, 132 + signingKey => $reserved_signing_key, 133 + })->status_is(200) 134 + ->json_is({}); 135 + 136 + my $signing_event = $app->store->list_events_from($handle_event->{seq} + 1, limit => 1)->[0]; 137 + is($signing_event->{type}, 'identity', 'admin.updateAccountSigningKey appends an identity event'); 138 + is($signing_event->{payload}{handle}, 'alice.external.test', 'signing-key identity event keeps the current handle'); 139 + 140 + $t->post_ok('/xrpc/com.atproto.admin.updateAccountSigningKey' => { 141 + Authorization => $admin_auth, 142 + } => json => { 143 + did => $did, 144 + signingKey => 'did:key:not-a-real-key', 145 + })->status_is(400) 146 + ->json_is('/error' => 'InvalidRequest') 147 + ->json_is('/message' => 'signingKey must be a valid secp256k1 did:key'); 148 + 149 + $t->post_ok('/xrpc/com.atproto.server.createInviteCodes' => { 150 + Authorization => $admin_auth, 151 + } => json => { 152 + codeCount => 2, 153 + useCount => 3, 154 + })->status_is(200) 155 + ->json_is('/codes/0/account' => 'admin'); 156 + 157 + is(scalar @{ $t->tx->res->json->{codes}[0]{codes} || [] }, 2, 'admin createInviteCodes returns the requested number of codes'); 158 + 159 + $t->post_ok('/xrpc/com.atproto.server.createInviteCodes' => { 160 + Authorization => "Bearer $access", 161 + } => json => { 162 + codeCount => 2, 163 + useCount => 1, 164 + forAccounts => [$did], 165 + })->status_is(200) 166 + ->json_is('/codes/0/account' => $did); 167 + 168 + $t->post_ok('/xrpc/com.atproto.server.createInviteCodes' => { 169 + Authorization => "Bearer $access", 170 + } => json => { 171 + codeCount => 1, 172 + useCount => 1, 173 + forAccounts => ['did:web:example.test:users:other'], 174 + })->status_is(400) 175 + ->json_is('/error' => 'InvalidRequest'); 176 + 177 + $t->post_ok('/xrpc/com.atproto.sync.notifyOfUpdate' => json => { 178 + hostname => 'crawler.example.test', 179 + })->status_is(200) 180 + ->json_is({}); 181 + 182 + $t->get_ok('/xrpc/com.atproto.sync.getHostStatus' => form => { 183 + hostname => 'crawler.example.test', 184 + })->status_is(200) 185 + ->json_is('/hostname' => 'crawler.example.test') 186 + ->json_is('/status' => 'active'); 187 + 188 + $t->post_ok('/xrpc/com.atproto.admin.updateAccountPassword' => { 189 + Authorization => $admin_auth, 190 + } => json => { 191 + did => $did, 192 + password => 'new-hunter22', 193 + })->status_is(200) 194 + ->json_is({}); 195 + 196 + $t->get_ok('/xrpc/com.atproto.server.getSession' => { 197 + Authorization => "Bearer $access", 198 + })->status_is(401); 199 + 200 + $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 201 + identifier => 'alice.external.test', 202 + password => 'new-hunter22', 203 + })->status_is(200) 204 + ->json_has('/accessJwt'); 205 + 206 + $access = $t->tx->res->json->{accessJwt}; 207 + 208 + $t->post_ok('/xrpc/com.atproto.server.createAppPassword' => { 209 + Authorization => "Bearer $access", 210 + } => json => { 211 + name => 'revoke-me', 212 + })->status_is(200) 213 + ->json_like('/password' => qr/\w/); 214 + 215 + my $app_password = $t->tx->res->json->{password}; 216 + 217 + $t->post_ok('/xrpc/com.atproto.temp.revokeAccountCredentials' => json => { 218 + account => $did, 219 + })->status_is(401) 220 + ->json_is('/error' => 'AuthRequired'); 221 + 222 + $t->post_ok('/xrpc/com.atproto.temp.revokeAccountCredentials' => { 223 + Authorization => $admin_auth, 224 + } => json => { 225 + account => $did, 226 + })->status_is(200) 227 + ->json_is({}); 228 + 229 + $t->get_ok('/xrpc/com.atproto.server.getSession' => { 230 + Authorization => "Bearer $access", 231 + })->status_is(401); 232 + 233 + $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 234 + identifier => 'alice.example.test', 235 + password => $app_password, 236 + })->status_is(401) 237 + ->json_is('/error' => 'AuthRequired'); 238 + 239 + $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 240 + identifier => 'alice.example.test', 241 + password => 'new-hunter22', 242 + })->status_is(401) 243 + ->json_is('/error' => 'AuthRequired'); 244 + 245 + done_testing;