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.

Enforce granular ATProto OAuth permissions

alice f557a5c1 f215ed7d

+1275 -64
+41 -5
lib/ATProto/PDS/API/Misc.pm
··· 62 62 }); 63 63 64 64 $registry->register('com.atproto.identity.requestPlcOperationSignature', sub ($c, $endpoint) { 65 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 65 + my (undef, $account) = require_auth( 66 + $c, 67 + audience => TOKEN_AUD_ACCESS, 68 + required_permission => { 69 + type => 'identity', 70 + attr => '*', 71 + }, 72 + ); 66 73 xrpc_error(400, 'InvalidRequest', 'account does not have an email address') 67 74 unless defined($account->{email}) && length($account->{email}); 68 75 issue_account_action_token( ··· 76 83 }); 77 84 78 85 $registry->register('com.atproto.identity.signPlcOperation', sub ($c, $endpoint) { 79 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 86 + my (undef, $account) = require_auth( 87 + $c, 88 + audience => TOKEN_AUD_ACCESS, 89 + required_permission => { 90 + type => 'identity', 91 + attr => '*', 92 + }, 93 + ); 80 94 xrpc_error(400, 'InvalidRequest', 'PLC operations are only supported for did:plc accounts') 81 95 unless is_plc_did($account->{did}); 82 96 my $body = $c->req->json || {}; ··· 106 120 }); 107 121 108 122 $registry->register('com.atproto.identity.submitPlcOperation', sub ($c, $endpoint) { 109 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 123 + my (undef, $account) = require_auth( 124 + $c, 125 + audience => TOKEN_AUD_ACCESS, 126 + required_permission => { 127 + type => 'identity', 128 + attr => '*', 129 + }, 130 + ); 110 131 xrpc_error(400, 'InvalidRequest', 'PLC operations are only supported for did:plc accounts') 111 132 unless is_plc_did($account->{did}); 112 133 my $body = $c->req->json || {}; ··· 131 152 }); 132 153 133 154 $registry->register('com.atproto.identity.updateHandle', sub ($c, $endpoint) { 134 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 155 + my (undef, $account) = require_auth( 156 + $c, 157 + audience => TOKEN_AUD_ACCESS, 158 + required_permission => { 159 + type => 'identity', 160 + attr => 'handle', 161 + }, 162 + ); 135 163 my $body = $c->req->json || {}; 136 164 my $domain = $c->config_value('service_handle_domain', 'localhost'); 137 165 my $handle = normalize_handle($body->{handle}, $domain); ··· 167 195 }); 168 196 169 197 $registry->register('com.atproto.moderation.createReport', sub ($c, $endpoint) { 170 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 198 + my (undef, $account) = require_auth( 199 + $c, 200 + audience => TOKEN_AUD_ACCESS, 201 + required_permission => { 202 + type => 'rpc', 203 + aud => $c->service_proxy->_permission_audience_for_request($c, $endpoint->{id}), 204 + lxm => $endpoint->{id}, 205 + }, 206 + ); 171 207 my $body = $c->req->json || {}; 172 208 assert_report_allowed($c, $account, $body->{reasonType}); 173 209 my $row = $c->store->create_report(
+60 -8
lib/ATProto/PDS/API/Repo.pm
··· 13 13 14 14 use ATProto::PDS::API::Server qw(require_auth); 15 15 use ATProto::PDS::API::Util qw(blob_ref resolve_repo xrpc_error); 16 + use ATProto::PDS::Auth::OAuth qw( 17 + oauth_required_permission_scope 18 + oauth_scope_allows_permission 19 + ); 16 20 use ATProto::PDS::Constants qw(TOKEN_AUD_ACCESS); 17 21 use ATProto::PDS::Moderation qw(assert_record_readable assert_repo_readable assert_repo_writable is_record_takedown parse_at_uri); 18 22 use ATProto::PDS::Repo::CID; ··· 55 59 56 60 $registry->register('com.atproto.repo.applyWrites', sub ($c, $endpoint) { 57 61 my $body = $c->req->json || {}; 58 - my $account = _require_repo_owner($c, $body->{repo}); 62 + my ($claims, $account) = _require_repo_owner($c, $body->{repo}); 59 63 my @writes = map { _normalize_apply_writes_input($_) } @{ $body->{writes} || [] }; 64 + _assert_oauth_write_permissions($claims, \@writes); 60 65 my $commit = $c->repo_manager->apply_writes( 61 66 $account, 62 67 \@writes, ··· 104 109 }); 105 110 106 111 $registry->register('com.atproto.repo.uploadBlob', sub ($c, $endpoint) { 107 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 112 + my ($claims, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 108 113 assert_repo_writable($c, $account); 109 114 my $bytes = $c->req->body // q(); 115 + my $mime_type = $c->req->headers->content_type || 'application/octet-stream'; 116 + _assert_oauth_permission( 117 + $claims, 118 + type => 'blob', 119 + mime => $mime_type, 120 + ); 110 121 my $cid = ATProto::PDS::Repo::CID->for_raw($bytes)->to_string; 111 122 my $existing = $c->store->get_blob($cid); 112 123 xrpc_error(400, 'BlobTakenDown', 'Blob has been taken down') ··· 123 134 xrpc_error(500, 'StorageFailure', "Unable to write blob $cid"); 124 135 } 125 136 126 - my $mime_type = $c->req->headers->content_type || 'application/octet-stream'; 127 137 $c->observe_blob_ingress($mime_type, length($bytes)); 128 138 $c->store->put_blob( 129 139 cid => $cid, ··· 155 165 }); 156 166 157 167 $registry->register('com.atproto.repo.importRepo', sub ($c, $endpoint) { 158 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS, required_scope => 'full'); 168 + my ($claims, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS, required_scope => 'full'); 159 169 assert_repo_writable($c, $account); 170 + _assert_oauth_permission( 171 + $claims, 172 + type => 'account', 173 + attr => 'repo', 174 + action => 'manage', 175 + ); 160 176 xrpc_error(400, 'InvalidRequest', 'Service is not accepting repo imports') 161 177 unless $c->config_value('accepting_imports', 1); 162 178 my $car_bytes = $c->req->body // q(); ··· 173 189 xrpc_error(404, 'RepoNotFound', 'Repository was not found') unless $account; 174 190 xrpc_error(401, 'AuthRequired', 'Token is not authorized for that repo') unless ($claims->{sub} // '') eq $account->{did}; 175 191 assert_repo_writable($c, $account); 176 - return $account; 192 + return ($claims, $account); 177 193 } 178 194 179 195 sub _readable_repo ($c, $repo, %args) { ··· 246 262 } 247 263 248 264 sub _apply_single_write ($c, $body, $write, %args) { 249 - my $account = _require_repo_owner($c, $body->{repo}); 265 + my ($claims, $account) = _require_repo_owner($c, $body->{repo}); 266 + _assert_oauth_write_permissions($claims, [$write]); 250 267 my $commit = $c->repo_manager->apply_writes( 251 268 $account, 252 269 [$write], ··· 266 283 } 267 284 268 285 sub _put_record ($c, $body) { 269 - my $account = _require_repo_owner($c, $body->{repo}); 286 + my ($claims, $account) = _require_repo_owner($c, $body->{repo}); 270 287 my $did = $account->{did}; 271 288 my $collection = $body->{collection}; 272 289 my $rkey = $body->{rkey}; ··· 274 291 my $current = $c->store->get_record($did, $collection, $rkey); 275 292 my $record_bytes = encode_dag_cbor($body->{record}); 276 293 294 + _assert_oauth_write_permissions($claims, [ 295 + { 296 + action => 'create', 297 + collection => $collection, 298 + }, 299 + { 300 + action => 'update', 301 + collection => $collection, 302 + }, 303 + ]); 304 + 277 305 if ($current && defined($current->{record_bytes}) && $current->{record_bytes} eq $record_bytes) { 278 306 return { 279 307 uri => $uri, ··· 299 327 } 300 328 301 329 sub _delete_record ($c, $body) { 302 - my $account = _require_repo_owner($c, $body->{repo}); 330 + my ($claims, $account) = _require_repo_owner($c, $body->{repo}); 303 331 my $current = $c->store->get_record($account->{did}, $body->{collection}, $body->{rkey}); 304 332 return {} unless $current; 333 + _assert_oauth_write_permissions($claims, [{ 334 + action => 'delete', 335 + collection => $body->{collection}, 336 + }]); 305 337 my $commit = $c->repo_manager->apply_writes( 306 338 $account, 307 339 [{ ··· 314 346 return { 315 347 commit => _commit_view($commit), 316 348 }; 349 + } 350 + 351 + sub _assert_oauth_write_permissions ($claims, $writes) { 352 + return unless ($claims->{typ} // q()) eq 'oauth_access'; 353 + 354 + for my $write (@$writes) { 355 + _assert_oauth_permission( 356 + $claims, 357 + type => 'repo', 358 + action => $write->{action}, 359 + collection => $write->{collection}, 360 + ); 361 + } 362 + } 363 + 364 + sub _assert_oauth_permission ($claims, %required) { 365 + return unless ($claims->{typ} // q()) eq 'oauth_access'; 366 + return if oauth_scope_allows_permission($claims->{scope}, %required); 367 + my $needed = oauth_required_permission_scope(%required); 368 + xrpc_error(403, 'Forbidden', qq{Missing required scope "$needed"}); 317 369 } 318 370 319 371 sub _commit_view ($commit) {
+106 -19
lib/ATProto/PDS/API/Server.pm
··· 10 10 11 11 use ATProto::PDS::API::Helpers qw(find_account invite_code_view issue_account_action_token require_admin verify_account_password verify_login_password); 12 12 use ATProto::PDS::API::Util qw(iso8601 xrpc_error); 13 - use ATProto::PDS::Auth::OAuth qw(oauth_scope_allows oauth_scope_has_atproto); 13 + use ATProto::PDS::Auth::OAuth qw( 14 + oauth_scope_allows 15 + oauth_scope_allows_permission 16 + oauth_scope_has_atproto 17 + ); 14 18 use ATProto::PDS::Auth::JWT qw(decode_jwt encode_jwt encode_service_jwt); 15 19 use ATProto::PDS::Auth::Password qw(hash_password random_hex); 16 20 use ATProto::PDS::Constants qw( ··· 199 203 }); 200 204 201 205 $registry->register('com.atproto.server.getSession', sub ($c, $endpoint) { 202 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 203 - return session_view($account); 206 + my ($claims, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 207 + my %opts; 208 + if (($claims->{typ} // q()) eq 'oauth_access') { 209 + $opts{include_email} = oauth_scope_allows_permission( 210 + $claims->{scope}, 211 + type => 'account', 212 + attr => 'email', 213 + action => 'read', 214 + ) ? 1 : 0; 215 + } 216 + return session_view($account, %opts); 204 217 }); 205 218 206 219 $registry->register('com.atproto.server.refreshSession', sub ($c, $endpoint) { ··· 240 253 }); 241 254 242 255 $registry->register('com.atproto.server.createAppPassword', sub ($c, $endpoint) { 243 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS, required_scope => 'full'); 256 + my (undef, $account) = require_auth( 257 + $c, 258 + audience => TOKEN_AUD_ACCESS, 259 + required_scope => 'full', 260 + disallow_oauth => 1, 261 + ); 244 262 my $body = $c->req->json || {}; 245 263 my $name = $body->{name} // q(); 246 264 xrpc_error(400, 'InvalidRequest', 'App password name is required') unless length $name; ··· 263 281 }); 264 282 265 283 $registry->register('com.atproto.server.listAppPasswords', sub ($c, $endpoint) { 266 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 284 + my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS, disallow_oauth => 1); 267 285 my $rows = $c->store->list_app_passwords_by_did($account->{did}); 268 286 return { 269 287 passwords => [ ··· 279 297 }); 280 298 281 299 $registry->register('com.atproto.server.revokeAppPassword', sub ($c, $endpoint) { 282 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 300 + my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS, disallow_oauth => 1); 283 301 my $body = $c->req->json || {}; 284 302 my $name = $body->{name} // q(); 285 303 xrpc_error(400, 'InvalidRequest', 'App password name is required') unless length $name; ··· 296 314 }); 297 315 298 316 $registry->register('com.atproto.server.deactivateAccount', sub ($c, $endpoint) { 299 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS, required_scope => 'full'); 317 + my (undef, $account) = require_auth( 318 + $c, 319 + audience => TOKEN_AUD_ACCESS, 320 + required_scope => 'full', 321 + disallow_oauth => 1, 322 + ); 300 323 $c->store->update_account($account->{did}, deactivated_at => time); 301 324 $c->append_event( 302 325 did => $account->{did}, ··· 311 334 }); 312 335 313 336 $registry->register('com.atproto.server.activateAccount', sub ($c, $endpoint) { 314 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS, required_scope => 'full'); 337 + my (undef, $account) = require_auth( 338 + $c, 339 + audience => TOKEN_AUD_ACCESS, 340 + required_scope => 'full', 341 + disallow_oauth => 1, 342 + ); 315 343 $account = $c->store->update_account($account->{did}, deactivated_at => undef); 316 344 $c->append_event( 317 345 did => $account->{did}, ··· 387 415 }); 388 416 389 417 $registry->register('com.atproto.server.requestEmailConfirmation', sub ($c, $endpoint) { 390 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 418 + my (undef, $account) = require_auth( 419 + $c, 420 + audience => TOKEN_AUD_ACCESS, 421 + required_permission => { 422 + type => 'account', 423 + attr => 'email', 424 + action => 'manage', 425 + }, 426 + ); 391 427 return {} unless $account->{email}; 392 428 issue_account_action_token( 393 429 $c, ··· 400 436 }); 401 437 402 438 $registry->register('com.atproto.server.confirmEmail', sub ($c, $endpoint) { 439 + if (($c->req->headers->authorization // q()) =~ /\A(?:Bearer|DPoP)\s+/i) { 440 + require_auth( 441 + $c, 442 + audience => TOKEN_AUD_ACCESS, 443 + required_permission => { 444 + type => 'account', 445 + attr => 'email', 446 + action => 'manage', 447 + }, 448 + ); 449 + } 403 450 my $body = $c->req->json || {}; 404 451 my $token = _require_action_token($c, 405 452 token => $body->{token}, ··· 420 467 }); 421 468 422 469 $registry->register('com.atproto.server.requestEmailUpdate', sub ($c, $endpoint) { 423 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 470 + my (undef, $account) = require_auth( 471 + $c, 472 + audience => TOKEN_AUD_ACCESS, 473 + required_permission => { 474 + type => 'account', 475 + attr => 'email', 476 + action => 'manage', 477 + }, 478 + ); 424 479 my $token_required = defined $account->{email_confirmed_at} ? 1 : 0; 425 480 if ($token_required) { 426 481 issue_account_action_token( ··· 437 492 }); 438 493 439 494 $registry->register('com.atproto.server.updateEmail', sub ($c, $endpoint) { 440 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS, required_scope => 'full'); 495 + my (undef, $account) = require_auth( 496 + $c, 497 + audience => TOKEN_AUD_ACCESS, 498 + required_scope => 'full', 499 + disallow_oauth => 1, 500 + ); 441 501 my $body = $c->req->json || {}; 442 502 if (defined $account->{email_confirmed_at}) { 443 503 xrpc_error(400, 'TokenRequired', 'A confirmation token is required to update email') ··· 459 519 }); 460 520 461 521 $registry->register('com.atproto.server.requestAccountDelete', sub ($c, $endpoint) { 462 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS, required_scope => 'full'); 522 + my (undef, $account) = require_auth( 523 + $c, 524 + audience => TOKEN_AUD_ACCESS, 525 + required_scope => 'full', 526 + disallow_oauth => 1, 527 + ); 463 528 issue_account_action_token( 464 529 $c, 465 530 $account, ··· 471 536 }); 472 537 473 538 $registry->register('com.atproto.server.deleteAccount', sub ($c, $endpoint) { 474 - my ($claims, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS, required_scope => 'full'); 539 + my ($claims, $account) = require_auth( 540 + $c, 541 + audience => TOKEN_AUD_ACCESS, 542 + required_scope => 'full', 543 + disallow_oauth => 1, 544 + ); 475 545 my $body = $c->req->json || {}; 476 546 xrpc_error(401, 'AuthRequired', 'Token is not authorized for that repo') 477 547 unless ($claims->{sub} // q()) eq ($body->{did} // q()) && ($account->{did} // q()) eq ($body->{did} // q()); ··· 514 584 xrpc_error(400, 'InvalidRequest', 'Protected methods cannot be service-authenticated') 515 585 if length($normalized_lxm) && $PROTECTED_SERVICE_AUTH_METHOD{$normalized_lxm}; 516 586 my $scope = _canonical_access_scope($claims->{scope} // $session->{scope}); 517 - if (length($normalized_lxm) && _service_auth_method_requires_privileged_access($normalized_lxm) && !_scope_allows($scope, 'privileged')) { 587 + if (($claims->{typ} // q()) eq 'oauth_access') { 588 + my $rpc_lxm = length($normalized_lxm) ? $lxm : '*'; 589 + xrpc_error(403, 'Forbidden', qq{Missing required scope "} . 'rpc:' . $rpc_lxm . '?aud=' . $aud . q{"}) 590 + unless oauth_scope_allows_permission( 591 + $scope, 592 + type => 'rpc', 593 + aud => $aud, 594 + lxm => $rpc_lxm, 595 + ); 596 + } elsif (length($normalized_lxm) && _service_auth_method_requires_privileged_access($normalized_lxm) && !_scope_allows($scope, 'privileged')) { 518 597 xrpc_error(400, 'InvalidToken', 'Bad token scope'); 519 598 } 520 599 my $requested_exp = $c->param('exp'); ··· 592 671 }); 593 672 594 673 $registry->register('com.atproto.server.getAccountInviteCodes', sub ($c, $endpoint) { 595 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS, required_scope => 'full'); 674 + my (undef, $account) = require_auth( 675 + $c, 676 + audience => TOKEN_AUD_ACCESS, 677 + required_scope => 'full', 678 + disallow_oauth => 1, 679 + ); 596 680 my $rows = $c->store->list_invite_codes_for_account($account->{did}); 597 681 return { 598 682 codes => [ map { invite_code_view($c->store, $_) } @$rows ], ··· 600 684 }); 601 685 } 602 686 603 - sub session_view ($account) { 687 + sub session_view ($account, %opts) { 688 + my $include_email = exists $opts{include_email} ? $opts{include_email} : 1; 604 689 return { 605 690 handle => $account->{handle}, 606 691 did => $account->{did}, 607 692 didDoc => $account->{did_doc} || account_did_doc({}, $account), 608 - email => $account->{email}, 609 - emailConfirmed => defined($account->{email_confirmed_at}) ? JSON::PP::true : JSON::PP::false, 610 - emailAuthFactor => JSON::PP::false, 693 + ($include_email ? ( 694 + email => $account->{email}, 695 + emailConfirmed => defined($account->{email_confirmed_at}) ? JSON::PP::true : JSON::PP::false, 696 + emailAuthFactor => JSON::PP::false, 697 + ) : ()), 611 698 active => (!defined($account->{deactivated_at}) && !defined($account->{deleted_at})) 612 699 ? JSON::PP::true 613 700 : JSON::PP::false,
+25 -18
lib/ATProto/PDS/Auth/OAuth.pm
··· 18 18 use ATProto::PDS::API::Helpers qw(find_account verify_login_password); 19 19 use ATProto::PDS::API::Util qw(xrpc_error); 20 20 use ATProto::PDS::Auth::JWT qw(decode_jwt encode_jwt); 21 + use ATProto::PDS::Auth::OAuthScope qw( 22 + oauth_normalize_scope 23 + oauth_scope_allows 24 + oauth_scope_has_atproto 25 + oauth_required_permission_scope 26 + oauth_scope_allows_permission 27 + ); 21 28 use ATProto::PDS::Auth::Password qw(random_hex timing_safe_eq); 22 29 use ATProto::PDS::Constants qw(TOKEN_AUD_ACCESS TOKEN_AUD_REFRESH); 23 30 use ATProto::PDS::Moderation qw(assert_login_allowed is_repo_takedown); 24 31 use ATProto::PDS::Util::BaseX qw(base64url_decode base64url_encode); 25 32 26 33 our @EXPORT_OK = qw( 34 + oauth_normalize_scope 27 35 oauth_scope_allows 36 + oauth_scope_allows_permission 28 37 oauth_scope_has_atproto 38 + oauth_required_permission_scope 29 39 ); 30 40 31 41 has settings => sub { {} }; ··· 112 122 return _oauth_json_error($c, 400, 'invalid_dpop_proof', "$@") if $@; 113 123 114 124 my $redirect_uri = $body->{redirect_uri} // q(); 115 - my $scope = _normalize_scope($body->{scope} // q()); 125 + my $scope = oauth_normalize_scope($body->{scope} // q()); 116 126 return _oauth_json_error($c, 400, 'invalid_request', 'response_type must be code') 117 127 unless ($body->{response_type} // q()) eq 'code'; 128 + return _oauth_json_error($c, 400, 'invalid_scope', 'scope contains unsupported values') 129 + unless defined $scope; 118 130 return _oauth_json_error($c, 400, 'invalid_scope', 'scope must include atproto') 119 131 unless oauth_scope_has_atproto($scope); 120 132 return _oauth_json_error($c, 400, 'invalid_request', 'redirect_uri is required') ··· 387 399 ); 388 400 }; 389 401 if (my $err = $@) { 390 - xrpc_error(401, 'InvalidToken', "$err"); 402 + my $message = "$err"; 403 + $message =~ s/\s+at\s+\S+\s+line\s+\d+\.?\n?\z//; 404 + xrpc_error(401, 'InvalidToken', $message); 391 405 } 392 406 393 - my $scope = _normalize_scope($claims->{scope} // $session->{scope}); 407 + my $scope = oauth_normalize_scope($claims->{scope} // $session->{scope}); 408 + if ($opts{disallow_oauth}) { 409 + xrpc_error(403, 'Forbidden', 'OAuth credentials are not supported for this endpoint'); 410 + } 394 411 if ($opts{required_scope} && !oauth_scope_allows($scope, $opts{required_scope})) { 395 412 xrpc_error(400, 'InvalidToken', 'Bad token scope'); 413 + } 414 + if ($opts{required_permission} && !oauth_scope_allows_permission($scope, %{ $opts{required_permission} })) { 415 + my $needed = oauth_required_permission_scope(%{ $opts{required_permission} }); 416 + xrpc_error(403, 'Forbidden', qq{Missing required scope "$needed"}); 396 417 } 397 418 398 419 my $account = $c->store->get_account_by_did($claims->{sub}); 399 420 xrpc_error(401, 'InvalidToken', 'Token subject no longer exists') unless $account; 400 421 xrpc_error(401, 'InvalidToken', 'Token subject has been deleted') if defined $account->{deleted_at}; 401 422 return ($claims, $account, $session, $proof); 402 - } 403 - 404 - sub oauth_scope_has_atproto ($scope) { 405 - return scalar grep { $_ eq 'atproto' } split /\s+/, ($scope // q()); 406 - } 407 - 408 - sub oauth_scope_allows ($scope, $required_scope) { 409 - return 1 if oauth_scope_has_atproto($scope); 410 - return 0; 411 423 } 412 424 413 425 sub _exchange_authorization_code ($self, $c, $body, $client_auth, $dpop) { ··· 496 508 my $issuer = $self->_issuer; 497 509 my $secret = $self->_jwt_secret; 498 510 my $now = time; 499 - my $scope = _normalize_scope($session->{scope}); 511 + my $scope = oauth_normalize_scope($session->{scope}); 500 512 501 513 my $access_token = encode_jwt({ 502 514 iss => $issuer, ··· 758 770 </body> 759 771 </html> 760 772 HTML 761 - } 762 - 763 - sub _normalize_scope ($scope) { 764 - my %seen; 765 - return join ' ', grep { !$seen{$_}++ } grep { length } split /\s+/, ($scope // q()); 766 773 } 767 774 768 775 sub _client_auth_matches ($request, $client_auth) {
+520
lib/ATProto/PDS/Auth/OAuthScope.pm
··· 1 + package ATProto::PDS::Auth::OAuthScope; 2 + 3 + use v5.34; 4 + use warnings; 5 + use feature 'signatures'; 6 + no warnings 'experimental::signatures'; 7 + 8 + use Exporter 'import'; 9 + use Mojo::Parameters; 10 + use Mojo::Util qw(url_unescape); 11 + 12 + our @EXPORT_OK = qw( 13 + oauth_normalize_scope 14 + oauth_scope_allows 15 + oauth_scope_allows_permission 16 + oauth_scope_has_atproto 17 + oauth_required_permission_scope 18 + ); 19 + 20 + my %PARSED_SCOPE_CACHE; 21 + my %NORMALIZED_SCOPE_CACHE; 22 + 23 + sub oauth_normalize_scope ($scope) { 24 + $scope //= q(); 25 + return $NORMALIZED_SCOPE_CACHE{$scope} if exists $NORMALIZED_SCOPE_CACHE{$scope}; 26 + 27 + my %seen; 28 + my @normalized; 29 + 30 + for my $token (grep { length } split /\s+/, $scope) { 31 + my $normalized = _normalize_scope_token($token); 32 + return $NORMALIZED_SCOPE_CACHE{$scope} = undef unless defined $normalized; 33 + next if $seen{$normalized}++; 34 + push @normalized, $normalized; 35 + } 36 + 37 + return $NORMALIZED_SCOPE_CACHE{$scope} = join ' ', sort @normalized; 38 + } 39 + 40 + sub oauth_scope_has_atproto ($scope) { 41 + return _parse_scope($scope)->{static}{atproto} ? 1 : 0; 42 + } 43 + 44 + sub oauth_scope_allows ($scope, $required_scope) { 45 + return 1 if !defined($required_scope) || !length($required_scope); 46 + my $normalized = oauth_normalize_scope($scope); 47 + return 0 unless defined $normalized; 48 + my %granted = map { $_ => 1 } grep { length } split /\s+/, $normalized; 49 + return $granted{$required_scope} ? 1 : 0; 50 + } 51 + 52 + sub oauth_scope_allows_permission ($scope, %required) { 53 + my $parsed = _parse_scope($scope); 54 + my $type = $required{type} // q(); 55 + return 0 unless length $type; 56 + 57 + return _allows_account($parsed, %required) if $type eq 'account'; 58 + return _allows_blob($parsed, %required) if $type eq 'blob'; 59 + return _allows_identity($parsed, %required) if $type eq 'identity'; 60 + return _allows_repo($parsed, %required) if $type eq 'repo'; 61 + return _allows_rpc($parsed, %required) if $type eq 'rpc'; 62 + return 0; 63 + } 64 + 65 + sub oauth_required_permission_scope (%required) { 66 + my $type = $required{type} // q(); 67 + return q() unless length $type; 68 + 69 + return _required_account_scope(%required) if $type eq 'account'; 70 + return _required_blob_scope(%required) if $type eq 'blob'; 71 + return _required_identity_scope(%required) if $type eq 'identity'; 72 + return _required_repo_scope(%required) if $type eq 'repo'; 73 + return _required_rpc_scope(%required) if $type eq 'rpc'; 74 + return q(); 75 + } 76 + 77 + sub _normalize_scope_token ($token) { 78 + return $token if $token eq 'atproto' 79 + || $token eq 'transition:email' 80 + || $token eq 'transition:generic' 81 + || $token eq 'transition:chat.bsky'; 82 + 83 + my ($prefix, $positional, $params) = _scope_syntax($token); 84 + return undef unless defined $prefix; 85 + 86 + if ($prefix eq 'account') { 87 + my $parsed = _parse_account_scope($positional, $params) or return undef; 88 + return _format_account_scope($parsed); 89 + } 90 + if ($prefix eq 'blob') { 91 + my $parsed = _parse_blob_scope($positional, $params) or return undef; 92 + return _format_blob_scope($parsed); 93 + } 94 + if ($prefix eq 'identity') { 95 + my $parsed = _parse_identity_scope($positional, $params) or return undef; 96 + return _format_identity_scope($parsed); 97 + } 98 + if ($prefix eq 'repo') { 99 + my $parsed = _parse_repo_scope($positional, $params) or return undef; 100 + return _format_repo_scope($parsed); 101 + } 102 + if ($prefix eq 'rpc') { 103 + my $parsed = _parse_rpc_scope($positional, $params) or return undef; 104 + return _format_rpc_scope($parsed); 105 + } 106 + 107 + return undef; 108 + } 109 + 110 + sub _parse_scope ($scope) { 111 + $scope //= q(); 112 + return $PARSED_SCOPE_CACHE{$scope} if exists $PARSED_SCOPE_CACHE{$scope}; 113 + 114 + my $parsed = { 115 + static => {}, 116 + account => [], 117 + blob => [], 118 + identity => [], 119 + repo => [], 120 + rpc => [], 121 + }; 122 + 123 + for my $token (grep { length } split /\s+/, $scope) { 124 + if ( 125 + $token eq 'atproto' 126 + || $token eq 'transition:email' 127 + || $token eq 'transition:generic' 128 + || $token eq 'transition:chat.bsky' 129 + ) { 130 + $parsed->{static}{$token} = 1; 131 + next; 132 + } 133 + 134 + my ($prefix, $positional, $params) = _scope_syntax($token); 135 + next unless defined $prefix; 136 + 137 + if ($prefix eq 'account') { 138 + my $entry = _parse_account_scope($positional, $params); 139 + push @{ $parsed->{account} }, $entry if $entry; 140 + next; 141 + } 142 + if ($prefix eq 'blob') { 143 + my $entry = _parse_blob_scope($positional, $params); 144 + push @{ $parsed->{blob} }, $entry if $entry; 145 + next; 146 + } 147 + if ($prefix eq 'identity') { 148 + my $entry = _parse_identity_scope($positional, $params); 149 + push @{ $parsed->{identity} }, $entry if $entry; 150 + next; 151 + } 152 + if ($prefix eq 'repo') { 153 + my $entry = _parse_repo_scope($positional, $params); 154 + push @{ $parsed->{repo} }, $entry if $entry; 155 + next; 156 + } 157 + if ($prefix eq 'rpc') { 158 + my $entry = _parse_rpc_scope($positional, $params); 159 + push @{ $parsed->{rpc} }, $entry if $entry; 160 + next; 161 + } 162 + } 163 + 164 + return $PARSED_SCOPE_CACHE{$scope} = $parsed; 165 + } 166 + 167 + sub _scope_syntax ($token) { 168 + my $param_idx = index($token, '?'); 169 + my $colon_idx = index($token, ':'); 170 + 171 + my $prefix_end = -1; 172 + if ($param_idx >= 0 && $colon_idx >= 0) { 173 + $prefix_end = $param_idx < $colon_idx ? $param_idx : $colon_idx; 174 + } elsif ($param_idx >= 0) { 175 + $prefix_end = $param_idx; 176 + } elsif ($colon_idx >= 0) { 177 + $prefix_end = $colon_idx; 178 + } 179 + 180 + my $prefix = $prefix_end >= 0 ? substr($token, 0, $prefix_end) : $token; 181 + return unless length $prefix; 182 + 183 + my $positional; 184 + if ($colon_idx >= 0 && ($param_idx < 0 || $colon_idx < $param_idx)) { 185 + $positional = $param_idx >= 0 186 + ? url_unescape(substr($token, $colon_idx + 1, $param_idx - $colon_idx - 1)) 187 + : url_unescape(substr($token, $colon_idx + 1)); 188 + } 189 + 190 + my $params; 191 + if ($param_idx >= 0 && $param_idx < (length($token) - 1)) { 192 + $params = Mojo::Parameters->new(substr($token, $param_idx + 1)); 193 + } else { 194 + $params = Mojo::Parameters->new; 195 + } 196 + 197 + return ($prefix, $positional, $params); 198 + } 199 + 200 + sub _allowed_params ($params, @allowed) { 201 + my %allowed = map { $_ => 1 } @allowed; 202 + for my $name (@{ $params->names }) { 203 + return 0 unless $allowed{$name}; 204 + } 205 + return 1; 206 + } 207 + 208 + sub _single_param ($params, $name) { 209 + my @values = @{ $params->every_param($name) }; 210 + return undef unless @values; 211 + return undef if @values > 1; 212 + return $values[0]; 213 + } 214 + 215 + sub _multi_param ($params, $name) { 216 + my @values = @{ $params->every_param($name) }; 217 + return undef unless @values; 218 + return \@values; 219 + } 220 + 221 + sub _parse_account_scope ($positional, $params) { 222 + return unless defined $positional && length $positional; 223 + return unless $positional eq 'email' || $positional eq 'repo' || $positional eq 'status'; 224 + return unless _allowed_params($params, 'action'); 225 + 226 + my $actions = _multi_param($params, 'action') // ['read']; 227 + my %valid = map { $_ => 1 } qw(read manage); 228 + return if grep { !$valid{$_} } @$actions; 229 + my %wanted = map { $_ => 1 } @$actions; 230 + my @normalized = grep { $wanted{$_} } qw(manage read); 231 + @normalized = ('read') unless @normalized; 232 + 233 + return { 234 + attr => $positional, 235 + action => \@normalized, 236 + }; 237 + } 238 + 239 + sub _parse_identity_scope ($positional, $params) { 240 + return unless defined $positional && length $positional; 241 + return unless $positional eq 'handle' || $positional eq '*'; 242 + return unless _allowed_params($params); 243 + return { 244 + attr => $positional, 245 + }; 246 + } 247 + 248 + sub _parse_blob_scope ($positional, $params) { 249 + return unless _allowed_params($params, 'accept'); 250 + my $accept = defined($positional) ? [$positional] : _multi_param($params, 'accept'); 251 + return unless $accept && @$accept; 252 + return if grep { !_is_accept($_) } @$accept; 253 + 254 + my %seen; 255 + my @normalized = grep { !$seen{$_}++ } map { lc $_ } @$accept; 256 + @normalized = ('*/*') if grep { $_ eq '*/*' } @normalized; 257 + if (!grep { $_ eq '*/*' } @normalized) { 258 + my %wildcards = map { $_ => 1 } grep { /\A[^\/]+\/\*\z/ } @normalized; 259 + @normalized = grep { 260 + my ($base) = split m{/}, $_, 2; 261 + !$wildcards{"$base/*"} || $_ eq "$base/*" 262 + } @normalized; 263 + @normalized = sort @normalized; 264 + } 265 + 266 + return { 267 + accept => \@normalized, 268 + }; 269 + } 270 + 271 + sub _parse_repo_scope ($positional, $params) { 272 + return unless _allowed_params($params, 'collection', 'action'); 273 + return if defined($positional) && defined _single_param($params, 'collection'); 274 + 275 + my $collections = defined($positional) ? [$positional] : _multi_param($params, 'collection'); 276 + return unless $collections && @$collections; 277 + return if grep { !_is_collection($_) } @$collections; 278 + 279 + my $actions = _multi_param($params, 'action') // [qw(create update delete)]; 280 + my %valid_action = map { $_ => 1 } qw(create update delete); 281 + return if grep { !$valid_action{$_} } @$actions; 282 + 283 + my %collection_seen; 284 + my @normalized_collections = grep { !$collection_seen{$_}++ } @$collections; 285 + @normalized_collections = ('*') if grep { $_ eq '*' } @normalized_collections; 286 + @normalized_collections = sort @normalized_collections unless @normalized_collections == 1 && $normalized_collections[0] eq '*'; 287 + 288 + my %action_seen = map { $_ => 1 } @$actions; 289 + my @normalized_actions = grep { $action_seen{$_} } qw(create update delete); 290 + 291 + return { 292 + collection => \@normalized_collections, 293 + action => \@normalized_actions, 294 + }; 295 + } 296 + 297 + sub _parse_rpc_scope ($positional, $params) { 298 + return unless _allowed_params($params, 'aud', 'lxm'); 299 + return if defined($positional) && defined _multi_param($params, 'lxm'); 300 + 301 + my $aud = _single_param($params, 'aud'); 302 + return unless defined($aud) && length($aud); 303 + return unless $aud eq '*' || $aud !~ /\s/; 304 + 305 + my $lxm = defined($positional) ? [$positional] : _multi_param($params, 'lxm'); 306 + return unless $lxm && @$lxm; 307 + return if grep { !_is_lxm($_) } @$lxm; 308 + return if $aud eq '*' && grep { $_ eq '*' } @$lxm; 309 + 310 + my %seen; 311 + my @normalized_lxm = grep { !$seen{$_}++ } @$lxm; 312 + @normalized_lxm = ('*') if grep { $_ eq '*' } @normalized_lxm; 313 + @normalized_lxm = sort @normalized_lxm unless @normalized_lxm == 1 && $normalized_lxm[0] eq '*'; 314 + 315 + return { 316 + aud => $aud, 317 + lxm => \@normalized_lxm, 318 + }; 319 + } 320 + 321 + sub _allows_account ($parsed, %required) { 322 + my $attr = $required{attr} // q(); 323 + return 0 unless length $attr; 324 + my $action = $required{action} // 'read'; 325 + 326 + return 1 327 + if $attr eq 'email' 328 + && $action eq 'read' 329 + && $parsed->{static}{'transition:email'}; 330 + 331 + for my $permission (@{ $parsed->{account} }) { 332 + next unless $permission->{attr} eq $attr; 333 + return 1 if grep { $_ eq 'manage' || $_ eq $action } @{ $permission->{action} }; 334 + } 335 + return 0; 336 + } 337 + 338 + sub _allows_identity ($parsed, %required) { 339 + my $attr = $required{attr} // q(); 340 + return 0 unless length $attr; 341 + for my $permission (@{ $parsed->{identity} }) { 342 + return 1 if $permission->{attr} eq '*' || $permission->{attr} eq $attr; 343 + } 344 + return 0; 345 + } 346 + 347 + sub _allows_blob ($parsed, %required) { 348 + return 1 if $parsed->{static}{'transition:generic'}; 349 + 350 + my $mime = lc($required{mime} // q()); 351 + return 0 unless _is_mime($mime); 352 + for my $permission (@{ $parsed->{blob} }) { 353 + return 1 if _matches_any_accept($permission->{accept}, $mime); 354 + } 355 + return 0; 356 + } 357 + 358 + sub _allows_repo ($parsed, %required) { 359 + return 1 if $parsed->{static}{'transition:generic'}; 360 + 361 + my $collection = $required{collection} // q(); 362 + my $action = $required{action} // q(); 363 + return 0 unless _is_collection($collection); 364 + return 0 unless $action eq 'create' || $action eq 'update' || $action eq 'delete'; 365 + 366 + for my $permission (@{ $parsed->{repo} }) { 367 + next unless grep { $_ eq $action } @{ $permission->{action} }; 368 + return 1 if grep { $_ eq '*' || $_ eq $collection } @{ $permission->{collection} }; 369 + } 370 + return 0; 371 + } 372 + 373 + sub _allows_rpc ($parsed, %required) { 374 + my $aud = $required{aud} // q(); 375 + my $lxm = $required{lxm} // q(); 376 + return 0 unless length($aud) && length($lxm); 377 + 378 + if ($parsed->{static}{'transition:generic'}) { 379 + return 1 if $lxm eq '*'; 380 + return 1 if $lxm !~ /\Achat\.bsky\./; 381 + } 382 + if ($parsed->{static}{'transition:chat.bsky'}) { 383 + return 1 if $lxm =~ /\Achat\.bsky\./; 384 + } 385 + 386 + for my $permission (@{ $parsed->{rpc} }) { 387 + next unless $permission->{aud} eq '*' || $permission->{aud} eq $aud; 388 + return 1 if grep { $_ eq '*' || $_ eq $lxm } @{ $permission->{lxm} }; 389 + } 390 + return 0; 391 + } 392 + 393 + sub _required_account_scope (%required) { 394 + my $attr = $required{attr} // q(); 395 + my $action = $required{action} // 'read'; 396 + return q() unless length $attr; 397 + return "account:$attr" if $action eq 'read'; 398 + return "account:$attr?action=$action"; 399 + } 400 + 401 + sub _required_identity_scope (%required) { 402 + my $attr = $required{attr} // q(); 403 + return length($attr) ? "identity:$attr" : q(); 404 + } 405 + 406 + sub _required_blob_scope (%required) { 407 + my $mime = lc($required{mime} // q()); 408 + return length($mime) ? "blob:$mime" : q(); 409 + } 410 + 411 + sub _required_repo_scope (%required) { 412 + my $collection = $required{collection} // q(); 413 + my $action = $required{action} // q(); 414 + return q() unless length($collection) && length($action); 415 + return "repo:$collection?action=$action"; 416 + } 417 + 418 + sub _required_rpc_scope (%required) { 419 + my $aud = $required{aud} // q(); 420 + my $lxm = $required{lxm} // q(); 421 + return q() unless length($aud) && length($lxm); 422 + return "rpc:$lxm?aud=$aud"; 423 + } 424 + 425 + sub _format_account_scope ($parsed) { 426 + return "account:$parsed->{attr}" 427 + if @{ $parsed->{action} } == 1 && $parsed->{action}[0] eq 'read'; 428 + my $params = Mojo::Parameters->new; 429 + $params->append(action => $_) for @{ $parsed->{action} }; 430 + return "account:$parsed->{attr}?" . $params->to_string; 431 + } 432 + 433 + sub _format_blob_scope ($parsed) { 434 + return 'blob:' . $parsed->{accept}[0] 435 + if @{ $parsed->{accept} } == 1; 436 + my $params = Mojo::Parameters->new; 437 + $params->append(accept => $_) for @{ $parsed->{accept} }; 438 + return 'blob?' . $params->to_string; 439 + } 440 + 441 + sub _format_identity_scope ($parsed) { 442 + return "identity:$parsed->{attr}"; 443 + } 444 + 445 + sub _format_repo_scope ($parsed) { 446 + my $scope = @{ $parsed->{collection} } == 1 447 + ? 'repo:' . $parsed->{collection}[0] 448 + : do { 449 + my $params = Mojo::Parameters->new; 450 + $params->append(collection => $_) for @{ $parsed->{collection} }; 451 + 'repo?' . $params->to_string; 452 + }; 453 + 454 + my %default = map { $_ => 1 } qw(create update delete); 455 + my $is_default = @{ $parsed->{action} } == 3 456 + && !grep { !$default{$_} } @{ $parsed->{action} }; 457 + return $scope if $is_default; 458 + 459 + my $params = Mojo::Parameters->new; 460 + if ($scope =~ /\?(.+)\z/) { 461 + $params = Mojo::Parameters->new($1); 462 + $scope =~ s/\?.+\z//; 463 + } 464 + $params->append(action => $_) for @{ $parsed->{action} }; 465 + return $scope . '?' . $params->to_string; 466 + } 467 + 468 + sub _format_rpc_scope ($parsed) { 469 + my $scope = @{ $parsed->{lxm} } == 1 470 + ? 'rpc:' . $parsed->{lxm}[0] 471 + : 'rpc'; 472 + my $params = Mojo::Parameters->new; 473 + if (@{ $parsed->{lxm} } > 1) { 474 + $params->append(lxm => $_) for @{ $parsed->{lxm} }; 475 + } 476 + $params->append(aud => $parsed->{aud}); 477 + return $scope . '?' . $params->to_string; 478 + } 479 + 480 + sub _is_collection ($value) { 481 + return 1 if defined($value) && $value eq '*'; 482 + return _is_nsid($value); 483 + } 484 + 485 + sub _is_lxm ($value) { 486 + return 1 if defined($value) && $value eq '*'; 487 + return _is_nsid($value); 488 + } 489 + 490 + sub _is_nsid ($value) { 491 + return 0 unless defined($value) && length($value); 492 + return $value =~ /\A[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+\z/ ? 1 : 0; 493 + } 494 + 495 + sub _is_accept ($value) { 496 + return 0 unless defined($value) && length($value); 497 + return 1 if $value eq '*/*'; 498 + return 1 if $value =~ /\A[^\/\s]+\/\*\z/; 499 + return _is_mime($value); 500 + } 501 + 502 + sub _is_mime ($value) { 503 + return 0 unless defined($value) && length($value); 504 + return $value =~ /\A[^\/\s*]+\/[^\/\s*]+\z/ ? 1 : 0; 505 + } 506 + 507 + sub _matches_any_accept ($accepts, $mime) { 508 + for my $accept (@$accepts) { 509 + return 1 if $accept eq '*/*'; 510 + if ($accept =~ m{\A([^/\s*]+)/\*\z}) { 511 + my $prefix = $1; 512 + return 1 if $mime =~ m{\A\Q$prefix\E/}; 513 + next; 514 + } 515 + return 1 if $accept eq $mime; 516 + } 517 + return 0; 518 + } 519 + 520 + 1;
+10 -1
lib/ATProto/PDS/ServiceProxy.pm
··· 53 53 ); 54 54 use ATProto::PDS::ServiceProxy::Upstream qw( 55 55 _config 56 + _permission_audience_for_request 56 57 _perform_upstream_request 57 58 _target_for_request 58 59 _target_from_proxy_header ··· 125 126 126 127 my $auth = $c->req->headers->authorization; 127 128 if (defined $auth && length $auth) { 128 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 129 + my (undef, $account) = require_auth( 130 + $c, 131 + audience => TOKEN_AUD_ACCESS, 132 + required_permission => { 133 + type => 'rpc', 134 + aud => $target->{aud}, 135 + lxm => $nsid, 136 + }, 137 + ); 129 138 xrpc_error(500, 'SigningKeyUnavailable', 'Account signing key is unavailable') 130 139 unless defined($account->{private_key}) && length($account->{private_key}); 131 140 $headers{Authorization} = 'Bearer ' . encode_service_jwt(
+36 -4
lib/ATProto/PDS/ServiceProxy/Preferences.pm
··· 25 25 xrpc_error(405, 'MethodNotAllowed', 'app.bsky.actor.getPreferences expects GET') 26 26 unless $c->req->method eq 'GET'; 27 27 28 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 28 + my (undef, $account) = require_auth( 29 + $c, 30 + audience => TOKEN_AUD_ACCESS, 31 + required_permission => { 32 + type => 'rpc', 33 + aud => $self->_permission_audience_for_request($c, 'app.bsky.actor.getPreferences'), 34 + lxm => 'app.bsky.actor.getPreferences', 35 + }, 36 + ); 29 37 my $preferences = $c->store->list_preferences($account->{did}, 'app.bsky'); 30 38 $c->render(json => { preferences => $preferences }); 31 39 return 200; ··· 35 43 xrpc_error(405, 'MethodNotAllowed', 'app.bsky.actor.putPreferences expects POST') 36 44 unless $c->req->method eq 'POST'; 37 45 38 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 46 + my (undef, $account) = require_auth( 47 + $c, 48 + audience => TOKEN_AUD_ACCESS, 49 + required_permission => { 50 + type => 'rpc', 51 + aud => $self->_permission_audience_for_request($c, 'app.bsky.actor.putPreferences'), 52 + lxm => 'app.bsky.actor.putPreferences', 53 + }, 54 + ); 39 55 my $body = $c->req->json || {}; 40 56 my $preferences = $body->{preferences}; 41 57 xrpc_error(400, 'InvalidRequest', 'preferences must be an array') ··· 57 73 xrpc_error(405, 'MethodNotAllowed', 'app.bsky.notification.getPreferences expects GET') 58 74 unless $c->req->method eq 'GET'; 59 75 60 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 76 + my (undef, $account) = require_auth( 77 + $c, 78 + audience => TOKEN_AUD_ACCESS, 79 + required_permission => { 80 + type => 'rpc', 81 + aud => $self->_permission_audience_for_request($c, 'app.bsky.notification.getPreferences'), 82 + lxm => 'app.bsky.notification.getPreferences', 83 + }, 84 + ); 61 85 my $preferences = $self->_load_notification_preferences($c, $account->{did}); 62 86 $c->render(json => { preferences => $preferences }); 63 87 return 200; ··· 67 91 xrpc_error(405, 'MethodNotAllowed', 'app.bsky.notification.putPreferencesV2 expects POST') 68 92 unless $c->req->method eq 'POST'; 69 93 70 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 94 + my (undef, $account) = require_auth( 95 + $c, 96 + audience => TOKEN_AUD_ACCESS, 97 + required_permission => { 98 + type => 'rpc', 99 + aud => $self->_permission_audience_for_request($c, 'app.bsky.notification.putPreferencesV2'), 100 + lxm => 'app.bsky.notification.putPreferencesV2', 101 + }, 102 + ); 71 103 my $body = $c->req->json || {}; 72 104 xrpc_error(400, 'InvalidRequest', 'notification preferences body must be an object') 73 105 unless ref($body) eq 'HASH';
+1 -1
lib/ATProto/PDS/ServiceProxy/Profile.pm
··· 31 31 32 32 my $account = resolve_repo($c, $actor) or return undef; 33 33 my $profile_value = $self->_profile_record_value($c, $account); 34 - my $viewer = $self->_optional_auth_account($c); 34 + my $viewer = $self->_optional_auth_account($c, 'app.bsky.actor.getProfile'); 35 35 my $result = { 36 36 %{ $self->_profile_view_detailed($c, $account, $profile_value) }, 37 37 associated => {
+15 -5
lib/ATProto/PDS/ServiceProxy/Threads.pm
··· 35 35 xrpc_error(400, 'InvalidRequest', 'actor is required') unless length $actor; 36 36 37 37 my $account = resolve_repo($c, $actor) or return undef; 38 - my $viewer = $self->_optional_auth_account($c); 38 + my $viewer = $self->_optional_auth_account($c, 'app.bsky.feed.getAuthorFeed'); 39 39 my $limit = $c->param('limit') // 50; 40 40 $limit = 1 if $limit < 1; 41 41 $limit = 100 if $limit > 100; ··· 70 70 my @resolved = map { $self->_resolve_local_post_uri($c, $_) } @uris; 71 71 return undef if grep { !defined $_ } @resolved; 72 72 73 - my $viewer = $self->_optional_auth_account($c); 73 + my $viewer = $self->_optional_auth_account($c, 'app.bsky.feed.getPosts'); 74 74 my @posts = map { 75 75 my ($account, $row) = @$_; 76 76 my $profile_value = $self->_profile_record_value($c, $account); ··· 90 90 91 91 my $resolved = $self->_resolve_local_post_uri($c, $uri) or return undef; 92 92 my ($account, $row) = @$resolved; 93 - my $viewer = $self->_optional_auth_account($c); 93 + my $viewer = $self->_optional_auth_account($c, 'app.bsky.feed.getPostThread'); 94 94 my $profile_value = $self->_profile_record_value($c, $account); 95 95 my $depth = $self->_non_negative_int_param($c, 'depth', 6); 96 96 my $parent_height = $self->_non_negative_int_param($c, 'parentHeight', 80); ··· 100 100 return 200; 101 101 } 102 102 103 - sub _optional_auth_account ($self, $c) { 103 + sub _optional_auth_account ($self, $c, $nsid) { 104 104 my $auth = $c->req->headers->authorization; 105 105 return undef unless defined $auth && length $auth; 106 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS); 106 + my %opts = ( 107 + audience => TOKEN_AUD_ACCESS, 108 + ); 109 + if (defined $nsid && length $nsid) { 110 + $opts{required_permission} = { 111 + type => 'rpc', 112 + aud => $self->_permission_audience_for_request($c, $nsid), 113 + lxm => $nsid, 114 + }; 115 + } 116 + my (undef, $account) = require_auth($c, %opts); 107 117 return $account; 108 118 } 109 119
+11 -1
lib/ATProto/PDS/ServiceProxy/Upstream.pm
··· 15 15 16 16 our @EXPORT_OK = qw( 17 17 _config 18 + _permission_audience_for_request 18 19 _perform_upstream_request 19 20 _target_for_request 20 21 _target_from_proxy_header ··· 78 79 } 79 80 80 81 return { 82 + aud => $self->_config('chat_service_did', 'did:web:api.bsky.chat') . '#' . SERVICE_ID_BSKY_CHAT, 81 83 did => $self->_config('chat_service_did', 'did:web:api.bsky.chat'), 82 84 url => $self->_config('chat_service_url', 'https://api.bsky.chat'), 83 85 } if $nsid =~ /\Achat\.bsky\./; 84 86 85 87 return { 88 + aud => $self->_config('bsky_appview_did', 'did:web:api.bsky.app') . '#' . SERVICE_ID_BSKY_APPVIEW, 86 89 did => $self->_config('bsky_appview_did', 'did:web:api.bsky.app'), 87 90 url => $self->_config('bsky_appview_url', 'https://api.bsky.app'), 88 - } if $nsid =~ /\Aapp\.bsky\./; 91 + } if $nsid =~ /\Aapp\.bsky\./ || $nsid eq 'com.atproto.moderation.createReport'; 89 92 90 93 return undef; 91 94 } ··· 100 103 101 104 my $appview_did = $self->_config('bsky_appview_did', 'did:web:api.bsky.app'); 102 105 return { 106 + aud => $proxy_to, 103 107 did => $appview_did, 104 108 url => $self->_config('bsky_appview_url', 'https://api.bsky.app'), 105 109 } if $did eq $appview_did && $service_id eq SERVICE_ID_BSKY_APPVIEW; 106 110 107 111 my $chat_did = $self->_config('chat_service_did', 'did:web:api.bsky.chat'); 108 112 return { 113 + aud => $proxy_to, 109 114 did => $chat_did, 110 115 url => $self->_config('chat_service_url', 'https://api.bsky.chat'), 111 116 } if $did eq $chat_did && $service_id eq SERVICE_ID_BSKY_CHAT; ··· 115 120 116 121 sub _config ($self, $key, $default) { 117 122 return $self->settings->{$key} // $default; 123 + } 124 + 125 + sub _permission_audience_for_request ($self, $c, $nsid) { 126 + my $target = $self->_target_for_request($c, $nsid); 127 + return $target ? $target->{aud} : undef; 118 128 } 119 129 120 130 1;
+337
t/oauth-permissions.t
··· 1 + use v5.34; 2 + use warnings; 3 + use feature 'signatures'; 4 + no warnings 'experimental::signatures'; 5 + 6 + use Config (); 7 + use Digest::SHA qw(sha256); 8 + use File::Path qw(remove_tree); 9 + use File::Spec; 10 + use FindBin qw($Bin); 11 + use JSON::PP qw(encode_json); 12 + use Test::More; 13 + 14 + BEGIN { 15 + require lib; 16 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 17 + lib->import( 18 + File::Spec->catdir($root, 'lib'), 19 + File::Spec->catdir($root, 'local', 'lib', 'perl5'), 20 + File::Spec->catdir($root, 'local', 'lib', 'perl5', $Config::Config{archname}), 21 + ); 22 + } 23 + 24 + use Crypt::PK::ECC; 25 + use Mojo::URL; 26 + use Test::Mojo; 27 + use ATProto::PDS; 28 + use ATProto::PDS::Util::BaseX qw(base64url_encode); 29 + 30 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 31 + my $tmp = File::Spec->catdir($root, 'data', 'tmp-tests', 'oauth-permissions'); 32 + remove_tree($tmp) if -d $tmp; 33 + 34 + my $config = { 35 + base_url => 'http://127.0.0.1:7755', 36 + service_did_method => 'did:web', 37 + service_handle_domain => 'localhost', 38 + jwt_secret => 'test-secret', 39 + data_dir => $tmp, 40 + db_path => File::Spec->catfile($tmp, 'perlsky.sqlite'), 41 + }; 42 + my $chat_aud = 'did:web:api.bsky.chat%23bsky_chat'; 43 + 44 + my $client_key = Crypt::PK::ECC->new; 45 + $client_key->generate_key('prime256v1'); 46 + my $client_private = $client_key->export_key_raw('private'); 47 + my $client_public = $client_key->export_key_raw('public'); 48 + my $client_jwk = _p256_public_jwk($client_public, 'oauth-permissions-key'); 49 + my $client_metadata = { 50 + client_id => 'https://client.example/permissions-metadata.json', 51 + client_name => 'Permission Test Client', 52 + client_uri => 'https://client.example', 53 + redirect_uris => ['https://client.example/callback'], 54 + grant_types => ['authorization_code', 'refresh_token'], 55 + response_types => ['code'], 56 + token_endpoint_auth_method => 'private_key_jwt', 57 + token_endpoint_auth_signing_alg => 'ES256', 58 + dpop_bound_access_tokens => JSON::PP::true, 59 + jwks => { keys => [$client_jwk] }, 60 + }; 61 + 62 + { 63 + no warnings 'redefine'; 64 + local *ATProto::PDS::Auth::OAuth::_load_client_metadata = sub ($self, $client_id) { 65 + die 'client metadata client_id did not match request' 66 + unless $client_id eq $client_metadata->{client_id}; 67 + return $client_metadata; 68 + }; 69 + 70 + my $t = Test::Mojo->new(ATProto::PDS->new( 71 + project_root => $root, 72 + settings => $config, 73 + )); 74 + 75 + $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 76 + handle => 'alice', 77 + email => 'alice@example.com', 78 + password => 'password123', 79 + })->status_is(200) 80 + ->json_is('/handle' => 'alice.localhost'); 81 + 82 + my $account = $t->tx->res->json; 83 + my $did = $account->{did}; 84 + 85 + my $atproto_only = _oauth_tokens_for_scope($t, $did, 'atproto'); 86 + $t->get_ok('/xrpc/com.atproto.server.getSession' => _oauth_headers($atproto_only->{access_token}, 'GET', $config->{base_url} . '/xrpc/com.atproto.server.getSession')) 87 + ->status_is(200) 88 + ->json_is('/did' => $did) 89 + ->json_hasnt('/email'); 90 + 91 + my $email_read = _oauth_tokens_for_scope($t, $did, 'atproto account:email'); 92 + $t->get_ok('/xrpc/com.atproto.server.getSession' => _oauth_headers($email_read->{access_token}, 'GET', $config->{base_url} . '/xrpc/com.atproto.server.getSession')) 93 + ->status_is(200) 94 + ->json_is('/email' => 'alice@example.com'); 95 + 96 + $t->post_ok('/xrpc/com.atproto.server.requestEmailConfirmation' => _oauth_headers($atproto_only->{access_token}, 'POST', $config->{base_url} . '/xrpc/com.atproto.server.requestEmailConfirmation') => json => {}) 97 + ->status_is(403) 98 + ->json_like('/message' => qr/account:email\?action=manage/); 99 + 100 + my $email_manage = _oauth_tokens_for_scope($t, $did, 'atproto account:email?action=manage'); 101 + $t->post_ok('/xrpc/com.atproto.server.requestEmailConfirmation' => _oauth_headers($email_manage->{access_token}, 'POST', $config->{base_url} . '/xrpc/com.atproto.server.requestEmailConfirmation') => json => {}) 102 + ->status_is(200) 103 + ->json_is({}); 104 + 105 + $t->post_ok('/xrpc/com.atproto.repo.createRecord' => _oauth_headers($atproto_only->{access_token}, 'POST', $config->{base_url} . '/xrpc/com.atproto.repo.createRecord') => json => { 106 + repo => $did, 107 + collection => 'app.bsky.feed.post', 108 + record => { 109 + '$type' => 'app.bsky.feed.post', 110 + text => 'forbidden write', 111 + createdAt => '2026-03-12T00:00:00Z', 112 + }, 113 + })->status_is(403) 114 + ->json_like('/message' => qr/repo:app\.bsky\.feed\.post\?action=create/); 115 + 116 + my $transition_generic = _oauth_tokens_for_scope($t, $did, 'atproto transition:generic'); 117 + $t->post_ok('/xrpc/com.atproto.repo.createRecord' => _oauth_headers($transition_generic->{access_token}, 'POST', $config->{base_url} . '/xrpc/com.atproto.repo.createRecord') => json => { 118 + repo => $did, 119 + collection => 'app.bsky.feed.post', 120 + record => { 121 + '$type' => 'app.bsky.feed.post', 122 + text => 'transition generic write', 123 + createdAt => '2026-03-12T00:00:01Z', 124 + }, 125 + })->status_is(200) 126 + ->json_like('/uri' => qr{\Aat://\Q$did\E/app\.bsky\.feed\.post/}); 127 + 128 + my $repo_create_only = _oauth_tokens_for_scope($t, $did, 'atproto repo:app.bsky.feed.post?action=create'); 129 + $t->post_ok('/xrpc/com.atproto.repo.putRecord' => _oauth_headers($repo_create_only->{access_token}, 'POST', $config->{base_url} . '/xrpc/com.atproto.repo.putRecord') => json => { 130 + repo => $did, 131 + collection => 'app.bsky.feed.post', 132 + rkey => 'new-post', 133 + record => { 134 + '$type' => 'app.bsky.feed.post', 135 + text => 'put record needs both create and update', 136 + createdAt => '2026-03-12T00:00:02Z', 137 + }, 138 + })->status_is(403) 139 + ->json_like('/message' => qr/repo:app\.bsky\.feed\.post\?action=update/); 140 + 141 + my $blob_png = _oauth_tokens_for_scope($t, $did, 'atproto blob:image/png'); 142 + $t->post_ok('/xrpc/com.atproto.repo.uploadBlob' => { 143 + %{ _oauth_headers($blob_png->{access_token}, 'POST', $config->{base_url} . '/xrpc/com.atproto.repo.uploadBlob') }, 144 + 'Content-Type' => 'image/png', 145 + } => 'png-data')->status_is(200) 146 + ->json_has('/blob/ref/$link'); 147 + 148 + $t->post_ok('/xrpc/com.atproto.repo.uploadBlob' => { 149 + %{ _oauth_headers($blob_png->{access_token}, 'POST', $config->{base_url} . '/xrpc/com.atproto.repo.uploadBlob') }, 150 + 'Content-Type' => 'image/jpeg', 151 + } => 'jpeg-data')->status_is(403) 152 + ->json_like('/message' => qr/blob:image\/jpeg/); 153 + 154 + $t->get_ok('/xrpc/app.bsky.actor.getPreferences' => _oauth_headers( 155 + $transition_generic->{access_token}, 156 + 'GET', 157 + $config->{base_url} . '/xrpc/app.bsky.actor.getPreferences', 158 + ))->status_is(200) 159 + ->json_is('/preferences' => []); 160 + 161 + $t->get_ok("/xrpc/com.atproto.server.getServiceAuth?aud=$chat_aud&lxm=chat.bsky.convo.getMessages" => _oauth_headers( 162 + $transition_generic->{access_token}, 163 + 'GET', 164 + $config->{base_url} . '/xrpc/com.atproto.server.getServiceAuth', 165 + ))->status_is(403) 166 + ->json_like('/message' => qr/rpc:chat\.bsky\.convo\.getMessages\?aud=did:web:api\.bsky\.chat#bsky_chat/); 167 + 168 + my $transition_chat = _oauth_tokens_for_scope($t, $did, 'atproto transition:chat.bsky'); 169 + $t->get_ok("/xrpc/com.atproto.server.getServiceAuth?aud=$chat_aud&lxm=chat.bsky.convo.getMessages" => _oauth_headers( 170 + $transition_chat->{access_token}, 171 + 'GET', 172 + $config->{base_url} . '/xrpc/com.atproto.server.getServiceAuth', 173 + ))->status_is(200) 174 + ->json_has('/token'); 175 + 176 + $t->post_ok('/xrpc/com.atproto.server.createAppPassword' => _oauth_headers( 177 + $transition_generic->{access_token}, 178 + 'POST', 179 + $config->{base_url} . '/xrpc/com.atproto.server.createAppPassword', 180 + ) => json => { name => 'oauth-unsupported' })->status_is(403) 181 + ->json_is('/message' => 'OAuth credentials are not supported for this endpoint'); 182 + 183 + $t->get_ok('/xrpc/com.atproto.server.getAccountInviteCodes' => _oauth_headers( 184 + $transition_generic->{access_token}, 185 + 'GET', 186 + $config->{base_url} . '/xrpc/com.atproto.server.getAccountInviteCodes', 187 + ))->status_is(403) 188 + ->json_is('/message' => 'OAuth credentials are not supported for this endpoint'); 189 + 190 + $t->post_ok('/xrpc/com.atproto.identity.updateHandle' => _oauth_headers( 191 + $atproto_only->{access_token}, 192 + 'POST', 193 + $config->{base_url} . '/xrpc/com.atproto.identity.updateHandle', 194 + ) => json => { handle => 'alice-renamed' })->status_is(403) 195 + ->json_like('/message' => qr/identity:handle/); 196 + 197 + my $identity_handle = _oauth_tokens_for_scope($t, $did, 'atproto identity:handle'); 198 + $t->post_ok('/xrpc/com.atproto.identity.updateHandle' => _oauth_headers( 199 + $identity_handle->{access_token}, 200 + 'POST', 201 + $config->{base_url} . '/xrpc/com.atproto.identity.updateHandle', 202 + ) => json => { handle => 'alice-renamed' })->status_is(200) 203 + ->json_is({}); 204 + } 205 + 206 + done_testing; 207 + 208 + sub _oauth_tokens_for_scope ($t, $identifier, $scope) { 209 + my $code_verifier = 'verifier-' . _random_hex(24); 210 + my $code_challenge = base64url_encode(sha256($code_verifier)); 211 + my $state = 'state-' . _random_hex(12); 212 + my $par_url = $config->{base_url} . '/oauth/par'; 213 + my $token_url = $config->{base_url} . '/oauth/token'; 214 + 215 + $t->post_ok('/oauth/par' => { 216 + DPoP => _dpop_jwt($client_jwk, $client_private, 'POST', $par_url), 217 + } => form => { 218 + client_id => $client_metadata->{client_id}, 219 + response_type => 'code', 220 + redirect_uri => $client_metadata->{redirect_uris}[0], 221 + scope => $scope, 222 + state => $state, 223 + login_hint => $identifier, 224 + code_challenge => $code_challenge, 225 + code_challenge_method => 'S256', 226 + client_assertion_type => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 227 + client_assertion => _client_assertion($client_metadata->{client_id}, $par_url, $client_jwk, $client_private), 228 + })->status_is(201) 229 + ->json_has('/request_uri'); 230 + 231 + my $request_uri = $t->tx->res->json->{request_uri}; 232 + 233 + $t->post_ok('/oauth/authorize' => form => { 234 + request_uri => $request_uri, 235 + identifier => $identifier, 236 + password => 'password123', 237 + decision => 'approve', 238 + })->status_is(302); 239 + 240 + my $callback = Mojo::URL->new($t->tx->res->headers->location); 241 + my $code = $callback->query->param('code'); 242 + 243 + $t->post_ok('/oauth/token' => { 244 + DPoP => _dpop_jwt($client_jwk, $client_private, 'POST', $token_url), 245 + } => form => { 246 + grant_type => 'authorization_code', 247 + client_id => $client_metadata->{client_id}, 248 + redirect_uri => $client_metadata->{redirect_uris}[0], 249 + code => $code, 250 + code_verifier => $code_verifier, 251 + client_assertion_type => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 252 + client_assertion => _client_assertion($client_metadata->{client_id}, $token_url, $client_jwk, $client_private), 253 + })->status_is(200) 254 + ->json_has('/access_token'); 255 + 256 + return $t->tx->res->json; 257 + } 258 + 259 + sub _oauth_headers ($access_token, $method, $url) { 260 + my $dpop_url = Mojo::URL->new($url); 261 + $dpop_url->query(undef); 262 + return { 263 + Authorization => "DPoP $access_token", 264 + DPoP => _dpop_jwt($client_jwk, $client_private, $method, $dpop_url->to_string, ath => $access_token), 265 + }; 266 + } 267 + 268 + sub _client_assertion ($client_id, $aud, $jwk, $private_key) { 269 + return _es256_jwt( 270 + { 271 + alg => 'ES256', 272 + typ => 'JWT', 273 + kid => $jwk->{kid}, 274 + }, 275 + { 276 + iss => $client_id, 277 + sub => $client_id, 278 + aud => $aud, 279 + jti => _random_hex(16), 280 + iat => time, 281 + exp => time + 300, 282 + }, 283 + $private_key, 284 + ); 285 + } 286 + 287 + sub _dpop_jwt ($jwk, $private_key, $method, $htu, %extra_claims) { 288 + my %claims = ( 289 + htm => uc($method), 290 + htu => $htu, 291 + jti => _random_hex(16), 292 + iat => time, 293 + ); 294 + if (defined(my $access_token = delete $extra_claims{ath})) { 295 + $claims{ath} = base64url_encode(sha256($access_token)); 296 + } 297 + %claims = (%claims, %extra_claims); 298 + 299 + return _es256_jwt( 300 + { 301 + alg => 'ES256', 302 + typ => 'dpop+jwt', 303 + jwk => { 304 + map { $_ => $jwk->{$_} } qw(kty crv x y), 305 + }, 306 + }, 307 + \%claims, 308 + $private_key, 309 + ); 310 + } 311 + 312 + sub _es256_jwt ($header, $claims, $private_key) { 313 + my $header_b64 = base64url_encode(encode_json($header)); 314 + my $claims_b64 = base64url_encode(encode_json($claims)); 315 + my $signing_str = join '.', $header_b64, $claims_b64; 316 + 317 + my $pk = Crypt::PK::ECC->new; 318 + $pk->import_key_raw($private_key, 'prime256v1'); 319 + my $sig = $pk->sign_message_rfc7518($signing_str, 'SHA256'); 320 + return join '.', $signing_str, base64url_encode($sig); 321 + } 322 + 323 + sub _p256_public_jwk ($public_key, $kid) { 324 + my $x = substr($public_key, 1, 32); 325 + my $y = substr($public_key, 33, 32); 326 + return { 327 + kty => 'EC', 328 + crv => 'P-256', 329 + kid => $kid, 330 + x => base64url_encode($x), 331 + y => base64url_encode($y), 332 + }; 333 + } 334 + 335 + sub _random_hex ($bytes) { 336 + return join q{}, map { sprintf '%02x', int(rand(256)) } 1 .. $bytes; 337 + }
+110
t/oauth-scopes.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::OAuthScope qw( 20 + oauth_normalize_scope 21 + oauth_scope_allows_permission 22 + oauth_scope_has_atproto 23 + oauth_required_permission_scope 24 + ); 25 + 26 + is( 27 + oauth_normalize_scope('atproto repo:app.bsky.feed.post?action=create&action=update transition:generic'), 28 + 'atproto repo:app.bsky.feed.post?action=create&action=update transition:generic', 29 + 'normalization preserves supported scope values', 30 + ); 31 + 32 + ok(oauth_scope_has_atproto('atproto transition:generic'), 'atproto marker is detected'); 33 + ok(!oauth_scope_has_atproto('transition:generic'), 'missing atproto marker is detected'); 34 + 35 + ok( 36 + oauth_scope_allows_permission('atproto transition:email', type => 'account', attr => 'email', action => 'read'), 37 + 'transition:email allows reading account email', 38 + ); 39 + ok( 40 + !oauth_scope_allows_permission('atproto transition:email', type => 'account', attr => 'email', action => 'manage'), 41 + 'transition:email does not allow managing account email', 42 + ); 43 + 44 + ok( 45 + oauth_scope_allows_permission('atproto transition:generic', type => 'repo', collection => 'app.bsky.feed.post', action => 'create'), 46 + 'transition:generic allows repo writes', 47 + ); 48 + ok( 49 + oauth_scope_allows_permission('atproto transition:generic', type => 'blob', mime => 'image/png'), 50 + 'transition:generic allows blob uploads', 51 + ); 52 + ok( 53 + oauth_scope_allows_permission('atproto transition:generic', type => 'rpc', aud => 'did:web:api.bsky.app#bsky_appview', lxm => 'app.bsky.actor.getProfile'), 54 + 'transition:generic allows non-chat rpc calls', 55 + ); 56 + ok( 57 + !oauth_scope_allows_permission('atproto transition:generic', type => 'rpc', aud => 'did:web:api.bsky.chat#bsky_chat', lxm => 'chat.bsky.convo.getMessages'), 58 + 'transition:generic does not allow chat rpc calls', 59 + ); 60 + ok( 61 + oauth_scope_allows_permission('atproto transition:chat.bsky', type => 'rpc', aud => 'did:web:api.bsky.chat#bsky_chat', lxm => 'chat.bsky.convo.getMessages'), 62 + 'transition:chat.bsky allows chat rpc calls', 63 + ); 64 + 65 + ok( 66 + oauth_scope_allows_permission('atproto repo:app.bsky.feed.post?action=create', type => 'repo', collection => 'app.bsky.feed.post', action => 'create'), 67 + 'granular repo permission allows the requested create action', 68 + ); 69 + ok( 70 + !oauth_scope_allows_permission('atproto repo:app.bsky.feed.post?action=create', type => 'repo', collection => 'app.bsky.feed.post', action => 'delete'), 71 + 'granular repo permission does not allow a different action', 72 + ); 73 + 74 + ok( 75 + oauth_scope_allows_permission('atproto blob:image/*', type => 'blob', mime => 'image/jpeg'), 76 + 'blob wildcard matches image subtype', 77 + ); 78 + ok( 79 + !oauth_scope_allows_permission('atproto blob:image/png', type => 'blob', mime => 'image/jpeg'), 80 + 'blob permission rejects a different mime type', 81 + ); 82 + 83 + ok( 84 + oauth_scope_allows_permission('atproto account:email?action=manage', type => 'account', attr => 'email', action => 'read'), 85 + 'account manage implies account read', 86 + ); 87 + ok( 88 + oauth_scope_allows_permission('atproto identity:*', type => 'identity', attr => 'handle'), 89 + 'identity wildcard matches handle updates', 90 + ); 91 + ok( 92 + oauth_scope_allows_permission('atproto rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview', type => 'rpc', aud => 'did:web:api.bsky.app#bsky_appview', lxm => 'app.bsky.actor.getProfile'), 93 + 'rpc permission matches exact audience and lxm', 94 + ); 95 + ok( 96 + oauth_scope_allows_permission('atproto rpc:app.bsky.actor.getProfile?aud=*', type => 'rpc', aud => 'did:web:api.bsky.app#bsky_appview', lxm => 'app.bsky.actor.getProfile'), 97 + 'rpc permission supports wildcard audience', 98 + ); 99 + ok( 100 + !oauth_scope_allows_permission('atproto rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview', type => 'rpc', aud => 'did:web:api.bsky.chat#bsky_chat', lxm => 'app.bsky.actor.getProfile'), 101 + 'rpc permission rejects a different audience', 102 + ); 103 + 104 + is( 105 + oauth_required_permission_scope(type => 'rpc', aud => 'did:web:api.bsky.app#bsky_appview', lxm => 'app.bsky.actor.getProfile'), 106 + 'rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview', 107 + 'required rpc scope renders in canonical form', 108 + ); 109 + 110 + done_testing;
+3 -2
t/oauth.t
··· 153 153 $t->get_ok('/xrpc/com.atproto.server.getAccountInviteCodes' => { 154 154 Authorization => "DPoP $access_token", 155 155 DPoP => _dpop_jwt($client_jwk, $client_private, 'GET', $config->{base_url} . '/xrpc/com.atproto.server.getAccountInviteCodes', ath => $access_token), 156 - })->status_is(200) 157 - ->json_is('/codes' => []); 156 + })->status_is(403) 157 + ->json_is('/error' => 'Forbidden') 158 + ->json_is('/message' => 'OAuth credentials are not supported for this endpoint'); 158 159 159 160 $t->post_ok('/oauth/token' => { 160 161 DPoP => _dpop_jwt($client_jwk, $client_private, 'POST', $token_url),