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.

Tighten email confirmation and OAuth metadata

alice 91069764 50691c64

+118 -9
+9
docs/DEPLOYMENT.md
··· 262 262 263 263 Modern third-party ATProto OAuth clients should now be able to discover and authenticate directly against your PDS. The built-in provider enforces both the transition scopes (`transition:generic`, `transition:email`, `transition:chat.bsky`), the granular ATProto permission families (`account:`, `identity:`, `repo:`, `blob:`, and `rpc:`), and `include:<nsid>` permission-set scopes. Permission-set scopes are resolved through lexicon records and compiled down to concrete repo/RPC permissions before tokens are issued, so apps requesting spec-compliant permission bundles still get least-privilege tokens. For example, a client like Tangled will start by fetching `/.well-known/oauth-protected-resource`, follow the advertised authorization-server metadata, submit a pushed authorization request, and then send the browser through `/oauth/authorize`. 264 264 265 + The local OAuth metadata only advertises the pieces perlsky actually implements today: authorization-code flow with PAR, PKCE `S256`, DPoP, `private_key_jwt` client auth, `response_mode=query`, and interactive `prompt=login` / `prompt=consent`. 266 + 265 267 ## First Account 266 268 267 269 You can create the first account directly with XRPC: ··· 286 288 - `refreshJwt` 287 289 288 290 Passwords must be at least 8 characters long. 291 + 292 + If you are running without outbound email during smoke/dev work, the safer testing knobs are: 293 + 294 + - `testing_auto_confirm_email`: mark new-account emails as confirmed immediately. 295 + - `testing_allow_unauthenticated_email_confirm`: allow `com.atproto.server.confirmEmail` without a bearer token for local testing only. 296 + 297 + Both are intended for testing environments. Leave them off in normal deployments. 289 298 290 299 If you want to disable open signup, enable `invite_code_required` and mint invite codes locally on the server: 291 300
+2 -2
docs/TEST_AUDIT.md
··· 45 45 - Firehose tests must not assume the smallest possible CAR diff. The reference runtime guarantees normalized behavior, not a minimal encoding. 46 46 - Label replay and cursor handling need exclusive replay semantics, proper future-cursor rejection, and forward progress across unhandled backlog events. 47 47 - `com.atproto.repo.listMissingBlobs` needed a real implementation rather than an always-empty placeholder. 48 - - ATProto OAuth `include:<nsid>` permission-set scopes need to be compiled into concrete repo/RPC permissions instead of being echoed back as inert strings. 48 + - ATProto OAuth `include:<nsid>` permission-set scopes are now compiled into concrete repo/RPC permissions before token issuance; local regression coverage pins that least-privilege behavior for supported and unsupported permissions. 49 49 - Deactivated accounts should still be able to establish and refresh sessions, but those responses must stay marked `active=false` with `status=deactivated`. 50 50 - Local `app.bsky.*` emulation must be conservative: only synthesize owner-local feed/thread data when the PDS can answer authoritatively, and proxy upstream instead of inventing partial global state. 51 51 - Account email handling needs consistent normalization on write, lookup, session creation, and confirmation checks; treating email case inconsistently leaves both tests and user-facing auth behavior brittle. ··· 57 57 58 58 These are not currently treated as audit failures: 59 59 60 - - Email confirmation remains testing-friendly by explicit user request because email sending is not configured in the current environment. 60 + - Email confirmation remains testing-friendly only behind the explicit `testing_allow_unauthenticated_email_confirm` / `testing_auto_confirm_email` toggles because email sending is not configured in the current environment. 61 61 - Admin auth still accepts a local bearer-token shortcut, while the official reference PDS expects Basic auth with `admin` credentials. 62 62 - Self-service invite creation exists only behind `self_service_invite_codes`; default behavior is admin-only invite minting. 63 63 - Label RPC parity is covered locally, but there is no like-for-like official local-labeler surface to diff against in the same way as core PDS endpoints.
+1 -1
lib/ATProto/PDS/API/Server.pm
··· 441 441 }); 442 442 443 443 $registry->register('com.atproto.server.confirmEmail', sub ($c, $endpoint) { 444 - if (($c->req->headers->authorization // q()) =~ /\A(?:Bearer|DPoP)\s+/i) { 444 + if (!$c->config_value('testing_allow_unauthenticated_email_confirm', 0)) { 445 445 require_auth( 446 446 $c, 447 447 audience => TOKEN_AUD_ACCESS,
+13 -2
lib/ATProto/PDS/Auth/OAuth.pm
··· 75 75 pushed_authorization_request_endpoint => $issuer . '/oauth/par', 76 76 jwks_uri => $issuer . '/oauth/jwks', 77 77 response_types_supported => ['code'], 78 - response_modes_supported => ['query', 'fragment', 'form_post'], 78 + response_modes_supported => ['query'], 79 79 grant_types_supported => ['authorization_code', 'refresh_token'], 80 80 code_challenge_methods_supported => ['S256'], 81 - prompt_values_supported => ['none', 'login', 'consent', 'select_account', 'create'], 81 + prompt_values_supported => ['login', 'consent'], 82 82 token_endpoint_auth_methods_supported => ['private_key_jwt', 'none'], 83 83 token_endpoint_auth_signing_alg_values_supported => ['ES256'], 84 84 dpop_signing_alg_values_supported => ['ES256'], ··· 141 141 unless length($body->{code_challenge} // q()); 142 142 return _oauth_json_error($c, 400, 'invalid_request', 'code_challenge_method must be S256') 143 143 unless ($body->{code_challenge_method} // q()) eq 'S256'; 144 + if (defined($body->{response_mode}) && length($body->{response_mode})) { 145 + return _oauth_json_error($c, 400, 'invalid_request', 'response_mode must be query') 146 + unless ($body->{response_mode} // q()) eq 'query'; 147 + } 148 + if (defined($body->{prompt}) && length($body->{prompt})) { 149 + my %allowed_prompt = map { $_ => 1 } qw(login consent); 150 + for my $prompt (split /\s+/, ($body->{prompt} // q())) { 151 + return _oauth_json_error($c, 400, 'invalid_request', 'prompt contains unsupported values') 152 + unless $allowed_prompt{$prompt}; 153 + } 154 + } 144 155 145 156 if (defined($body->{resource}) && length($body->{resource}) && ($body->{resource} ne $self->_issuer)) { 146 157 return _oauth_json_error($c, 400, 'invalid_target', 'resource is not supported');
+4 -1
t/app-routes.t
··· 69 69 ->json_is('/scopes_supported/0' => 'atproto') 70 70 ->json_is('/authorization_endpoint' => 'http://127.0.0.1:7755/oauth/authorize') 71 71 ->json_is('/token_endpoint' => 'http://127.0.0.1:7755/oauth/token') 72 - ->json_is('/pushed_authorization_request_endpoint' => 'http://127.0.0.1:7755/oauth/par'); 72 + ->json_is('/pushed_authorization_request_endpoint' => 'http://127.0.0.1:7755/oauth/par') 73 + ->json_is('/response_modes_supported/0' => 'query') 74 + ->json_is('/prompt_values_supported/0' => 'login') 75 + ->json_is('/prompt_values_supported/1' => 'consent'); 73 76 74 77 my $suffix = time . int(rand(1_000_000)); 75 78 my $routeprobe_handle = "routeprobe-$suffix.localhost";
+50 -2
t/email-confirmation.t
··· 22 22 23 23 my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 24 24 my $tmp = tempdir(CLEANUP => 1); 25 + my $bypass_tmp = tempdir(CLEANUP => 1); 25 26 26 27 my $app = ATProto::PDS->new( 27 28 project_root => $root, ··· 61 62 purpose => 'email_confirm', 62 63 ); 63 64 ok($token, 'email confirmation token was created'); 65 + 66 + $t->post_ok('/xrpc/com.atproto.server.confirmEmail' => json => { 67 + email => 'ALICE@example.test', 68 + token => $token->{token}, 69 + })->status_is(401) 70 + ->json_is('/error' => 'AuthRequired'); 64 71 65 72 $app->store->update_account( 66 73 $alice->{did}, 67 74 email => 'alice+new@example.test', 68 75 ); 69 76 70 - $t->post_ok('/xrpc/com.atproto.server.confirmEmail' => json => { 77 + $t->post_ok('/xrpc/com.atproto.server.confirmEmail' => { 78 + Authorization => "Bearer $alice->{accessJwt}", 79 + } => json => { 71 80 email => 'ALICE@example.test', 72 81 token => $token->{token}, 73 82 })->status_is(400) ··· 95 104 ); 96 105 ok($bob_token, 'case-insensitive confirmation flow also issues a token'); 97 106 98 - $t->post_ok('/xrpc/com.atproto.server.confirmEmail' => json => { 107 + $t->post_ok('/xrpc/com.atproto.server.confirmEmail' => { 108 + Authorization => "Bearer $bob->{accessJwt}", 109 + } => json => { 99 110 email => 'BOB@example.test', 100 111 token => $bob_token->{token}, 101 112 })->status_is(200) ··· 105 116 defined $app->store->get_account_by_did($bob->{did})->{email_confirmed_at}, 106 117 'email confirmation accepts case-insensitive email matches', 107 118 ); 119 + 120 + my $bypass_app = ATProto::PDS->new( 121 + project_root => $root, 122 + settings => { 123 + base_url => 'http://127.0.0.1:7755', 124 + service_handle_domain => 'example.test', 125 + service_did_method => 'did:web', 126 + jwt_secret => 'email-confirm-bypass-secret', 127 + testing_auto_confirm_email => 0, 128 + testing_allow_unauthenticated_email_confirm => 1, 129 + data_dir => $bypass_tmp, 130 + db_path => File::Spec->catfile($bypass_tmp, 'perlsky.sqlite'), 131 + }, 132 + ); 133 + my $bypass_t = Test::Mojo->new($bypass_app); 134 + 135 + $bypass_t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 136 + handle => 'carol.example.test', 137 + email => 'carol@example.test', 138 + password => 'hunter22', 139 + })->status_is(200); 140 + my $carol = $bypass_t->tx->res->json; 141 + 142 + $bypass_t->post_ok('/xrpc/com.atproto.server.requestEmailConfirmation' => { 143 + Authorization => "Bearer $carol->{accessJwt}", 144 + } => json => {})->status_is(200); 145 + my $carol_token = $bypass_app->store->latest_action_token( 146 + did => $carol->{did}, 147 + purpose => 'email_confirm', 148 + ); 149 + ok($carol_token, 'testing bypass app also issues a confirmation token'); 150 + 151 + $bypass_t->post_ok('/xrpc/com.atproto.server.confirmEmail' => json => { 152 + email => 'carol@example.test', 153 + token => $carol_token->{token}, 154 + })->status_is(200) 155 + ->json_is({}); 108 156 109 157 done_testing;
+3 -1
t/extended-api.t
··· 219 219 purpose => 'email_confirm', 220 220 ); 221 221 222 - $t->post_ok('/xrpc/com.atproto.server.confirmEmail' => json => { 222 + $t->post_ok('/xrpc/com.atproto.server.confirmEmail' => { 223 + Authorization => "Bearer $access", 224 + } => json => { 223 225 email => 'alice+new@example.test', 224 226 token => $email_confirm->{token}, 225 227 })->status_is(200);
+36
t/oauth.t
··· 102 102 103 103 my $request_uri = $t->tx->res->json->{request_uri}; 104 104 105 + $t->post_ok('/oauth/par' => { 106 + DPoP => _dpop_jwt($client_jwk, $client_private, 'POST', $par_url), 107 + } => form => { 108 + client_id => $client_metadata->{client_id}, 109 + response_type => 'code', 110 + response_mode => 'form_post', 111 + redirect_uri => $client_metadata->{redirect_uris}[0], 112 + scope => $client_metadata->{scope}, 113 + state => 'oauth-state-form-post', 114 + login_hint => 'alice.localhost', 115 + code_challenge => $code_challenge, 116 + code_challenge_method => 'S256', 117 + client_assertion_type => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 118 + client_assertion => _client_assertion($client_metadata->{client_id}, $par_url, $client_jwk, $client_private), 119 + })->status_is(400) 120 + ->json_is('/error' => 'invalid_request') 121 + ->json_is('/error_description' => 'response_mode must be query'); 122 + 123 + $t->post_ok('/oauth/par' => { 124 + DPoP => _dpop_jwt($client_jwk, $client_private, 'POST', $par_url), 125 + } => form => { 126 + client_id => $client_metadata->{client_id}, 127 + response_type => 'code', 128 + redirect_uri => $client_metadata->{redirect_uris}[0], 129 + scope => $client_metadata->{scope}, 130 + prompt => 'none', 131 + state => 'oauth-state-prompt-none', 132 + login_hint => 'alice.localhost', 133 + code_challenge => $code_challenge, 134 + code_challenge_method => 'S256', 135 + client_assertion_type => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 136 + client_assertion => _client_assertion($client_metadata->{client_id}, $par_url, $client_jwk, $client_private), 137 + })->status_is(400) 138 + ->json_is('/error' => 'invalid_request') 139 + ->json_is('/error_description' => 'prompt contains unsupported values'); 140 + 105 141 $t->get_ok(Mojo::URL->new('/oauth/authorize')->query(request_uri => $request_uri)->to_string) 106 142 ->status_is(200) 107 143 ->content_like(qr/Authorize Tangled Test Client/);