Mojo-ATProto-OAuth
view release on metacpan or search on metacpan
# succeeds. Returns a Mojo::UserAgent wired to it.
sub mock_par_ua ($expect_confidential = 0) {
my $nonce_seq = 0;
my $good_nonce;
my $app = Mojolicious::Lite->new;
$app->routes->post(
'/par' => sub ($c) {
my $dpop = $c->req->headers->header('DPoP');
return $c->render(text => 'missing DPoP header', status => 400) unless $dpop;
my ($h, $p, $s) = split(/\./, $dpop);
my $claims = decode_json(decode_base64url($p));
my $params = $c->req->body_params->to_hash;
if ($expect_confidential) {
return $c->render(json => {error => 'invalid_client'}, status => 400)
unless length($params->{client_assertion} // '');
}
if (!$good_nonce || ($claims->{nonce} // '') ne $good_nonce) {
$nonce_seq++;
$good_nonce = "nonce-$nonce_seq";
$c->res->headers->header('DPoP-Nonce' => $good_nonce);
return $c->render(json => {error => 'use_dpop_nonce'}, status => 400);
}
$c->res->headers->header('DPoP-Nonce' => $good_nonce);
return $c->render(json => {request_uri => 'urn:ietf:params:oauth:request_uri:req-123', expires_in => 60}, status => 201);
}
);
my $ua = Mojo::UserAgent->new;
$ua->server->app($app);
return $ua;
}
subtest 'send_auth_request retries once on use_dpop_nonce and succeeds' => sub {
my $oauth = Mojo::ATProto::OAuth->new(
client_id => 'https://pib.example.com/oauth/client-metadata.json',
callback_url => 'https://pib.example.com/oauth/callback',
scopes => ['atproto'],
ua => mock_par_ua(),
);
my $auth_meta = {
issuer => 'https://auth.example.com',
token_endpoint => 'https://auth.example.com/token',
revocation_endpoint => 'https://auth.example.com/revoke',
pushed_authorization_request_endpoint => '/par',
};
my $info = $oauth->send_auth_request($auth_meta, login_hint => 'alice.example.com');
is($info->{request_uri}, 'urn:ietf:params:oauth:request_uri:req-123');
is($info->{auth_server_url}, 'https://auth.example.com');
is($info->{auth_server_token_endpoint}, 'https://auth.example.com/token');
is($info->{scopes}, ['atproto']);
ok(length($info->{state}), 'state generated');
ok(length($info->{pkce_verifier}), 'pkce verifier generated');
ok(length($info->{dpop_authserver_nonce}), 'final DPoP nonce captured');
like($info->{dpop_private_key_pem}, qr/BEGIN (EC )?PRIVATE KEY/, 'DPoP private key persisted as PEM');
};
subtest 'send_auth_request_p (async) behaves the same as the sync path' => sub {
my $oauth = Mojo::ATProto::OAuth->new(
client_id => 'https://pib.example.com/oauth/client-metadata.json',
callback_url => 'https://pib.example.com/oauth/callback',
scopes => ['atproto'],
ua => mock_par_ua(),
);
my $auth_meta = {
issuer => 'https://auth.example.com',
token_endpoint => 'https://auth.example.com/token',
pushed_authorization_request_endpoint => '/par',
};
my $info;
$oauth->send_auth_request_p($auth_meta)->then(sub ($i) { $info = $i })->wait;
is($info->{request_uri}, 'urn:ietf:params:oauth:request_uri:req-123');
ok(length($info->{dpop_authserver_nonce}), 'final DPoP nonce captured');
};
subtest 'confidential client includes a client_assertion in the PAR body' => sub {
my $key = Mojo::ATProto::OAuth::DPoP->generate_keypair;
my $oauth = Mojo::ATProto::OAuth->new(
client_id => 'https://pib.example.com/oauth/client-metadata.json',
callback_url => 'https://pib.example.com/oauth/callback',
scopes => ['atproto'],
private_key => $key,
key_id => 'key-1',
ua => mock_par_ua(1),
);
ok($oauth->is_confidential, 'client reports itself confidential once a key is set');
my $auth_meta = {
issuer => 'https://auth.example.com',
token_endpoint => 'https://auth.example.com/token',
pushed_authorization_request_endpoint => '/par',
};
my $info = $oauth->send_auth_request($auth_meta);
is($info->{request_uri}, 'urn:ietf:params:oauth:request_uri:req-123');
};
subtest 'a non-nonce error is not retried and dies with the server-supplied reason' => sub {
my $app = Mojolicious::Lite->new;
$app->routes->post('/par' => sub ($c) { $c->render(json => {error => 'invalid_scope'}, status => 400) });
my $ua = Mojo::UserAgent->new;
$ua->server->app($app);
my $oauth = Mojo::ATProto::OAuth->new(
client_id => 'https://pib.example.com/oauth/client-metadata.json',
callback_url => 'https://pib.example.com/oauth/callback',
scopes => ['atproto'],
ua => $ua,
);
my $auth_meta = {
issuer => 'https://auth.example.com',
token_endpoint => 'https://auth.example.com/token',
pushed_authorization_request_endpoint => '/par',
};
my $err;
eval { $oauth->send_auth_request($auth_meta) } or $err = $@;
like($err, qr/invalid_scope/, 'error reason surfaced without a pointless nonce retry');
};
subtest 'new_localhost mimics indigo\'s loopback client for local-dev testing' => sub {
my $oauth = Mojo::ATProto::OAuth->new_localhost(
callback_url => 'http://127.0.0.1:3000/oauth/callback',
scopes => ['atproto', 'transition:generic'],
ua => mock_par_ua(),
);
is($oauth->client_id, 'http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Foauth%2Fcallback&scope=atproto+transition%3Ageneric',
'client_id is the fixed localhost sentinel with redirect_uri/scope query-encoded, matching indigo\'s NewLocalhostConfig');
ok($oauth->loopback, 'flagged as a loopback client');
ok(!$oauth->is_confidential, 'loopback clients are always public');
my $err;
eval { $oauth->client_metadata } or $err = $@;
like($err, qr/loopback clients don't serve/, 'client_metadata refuses to build a document for a loopback client_id');
my $auth_meta = {
issuer => 'https://auth.example.com',
token_endpoint => 'https://auth.example.com/token',
pushed_authorization_request_endpoint => '/par',
};
my $info = $oauth->send_auth_request($auth_meta);
is($info->{request_uri}, 'urn:ietf:params:oauth:request_uri:req-123', 'PAR still completes normally with a loopback client_id');
( run in 0.466 second using v1.01-cache-2.11-cpan-8dfa8b56332 )