Mojo-ATProto-OAuth
view release on metacpan or search on metacpan
t/refresh_and_scope_upgrade.t view on Meta::CPAN
return $c->render(text => 'missing DPoP header', status => 400) unless $c->req->headers->header('DPoP');
my $params = $c->req->body_params->to_hash;
if (($params->{grant_type} // '') eq 'refresh_token') {
return $c->render(json => {error => 'invalid_grant'}, status => 400) unless length($params->{refresh_token} // '');
return $c->render(
json => {
access_token => 'access-refreshed-' . int(rand(1_000_000)),
refresh_token => 'refresh-refreshed-' . int(rand(1_000_000)),
},
status => 200
);
}
my $code = $params->{code} // '';
return $c->render(json => {error => 'invalid_grant'}, status => 400) unless exists($scope_for_code{$code});
return $c->render(
json => {
sub => $expected_did,
scope => $scope_for_code{$code},
access_token => "access-$code",
refresh_token => "refresh-$code",
},
status => 200
);
}
);
my $ua = Mojo::UserAgent->new;
$ua->server->app($app);
return ($ua, sub { return $last_code });
}
sub make_oauth ($ua) {
return 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,
identity => Mojo::ATProto::OAuth::Identity->new,
resolver => Mojo::ATProto::OAuth::Resolver->new,
store => $store,
);
}
subtest 'refresh_tokens mints new tokens and persists them, reusing the same DPoP key' => sub {
no warnings 'redefine';
local *Mojo::ATProto::OAuth::Identity::lookup = sub ($self, $identifier) { return $stub_identity };
local *Mojo::ATProto::OAuth::Resolver::resolve_auth_server_url = sub ($self, $host) { return 'https://auth.example.com' };
local *Mojo::ATProto::OAuth::Resolver::resolve_auth_server_metadata = sub ($self, $url) { return $auth_meta };
my ($ua, $get_code) = mock_auth_server('did:plc:testuser');
my $oauth = make_oauth($ua);
$oauth->start_auth_flow(identifier => 'alice.example.com');
my $state = $store->last_state_for_issuer('https://auth.example.com');
my $session = $oauth->process_callback({state => $state, iss => 'https://auth.example.com', code => $get_code->()});
my $old_access = $session->{access_token};
my $old_refresh = $session->{refresh_token};
my $old_key_pem = $session->{dpop_private_key_pem};
my $refreshed = $oauth->refresh_tokens({%$session});
isnt($refreshed->{access_token}, $old_access, 'access token changed');
isnt($refreshed->{refresh_token}, $old_refresh, 'refresh token changed');
is($refreshed->{dpop_private_key_pem}, $old_key_pem, 'the DPoP key itself is never rotated on refresh');
my $stored = $store->get_session('did:plc:testuser', $state);
is($stored->{access_token}, $refreshed->{access_token}, 'refreshed tokens actually persisted');
$store->delete_session('did:plc:testuser', $state);
};
subtest 'refresh_tokens_p (async)' => sub {
no warnings 'redefine';
local *Mojo::ATProto::OAuth::Identity::lookup = sub ($self, $identifier) { return $stub_identity };
local *Mojo::ATProto::OAuth::Resolver::resolve_auth_server_url = sub ($self, $host) { return 'https://auth.example.com' };
local *Mojo::ATProto::OAuth::Resolver::resolve_auth_server_metadata = sub ($self, $url) { return $auth_meta };
my ($ua, $get_code) = mock_auth_server('did:plc:testuser');
my $oauth = make_oauth($ua);
$oauth->start_auth_flow(identifier => 'alice.example.com');
my $state = $store->last_state_for_issuer('https://auth.example.com');
my $session = $oauth->process_callback({state => $state, iss => 'https://auth.example.com', code => $get_code->()});
my $old_access = $session->{access_token};
my $refreshed;
$oauth->refresh_tokens_p({%$session})->then(sub ($s) { $refreshed = $s })->wait;
isnt($refreshed->{access_token}, $old_access);
$store->delete_session('did:plc:testuser', $state);
};
subtest 'start_scope_upgrade + process_callback: merges onto the existing session, does not replace it' => sub {
no warnings 'redefine';
local *Mojo::ATProto::OAuth::Identity::lookup = sub ($self, $identifier) { return $stub_identity };
local *Mojo::ATProto::OAuth::Resolver::resolve_auth_server_url = sub ($self, $host) { return 'https://auth.example.com' };
local *Mojo::ATProto::OAuth::Resolver::resolve_auth_server_metadata = sub ($self, $url) { return $auth_meta };
my ($ua, $get_code) = mock_auth_server('did:plc:testuser');
my $oauth = make_oauth($ua);
$oauth->start_auth_flow(identifier => 'alice.example.com');
my $login_state = $store->last_state_for_issuer('https://auth.example.com');
my $session = $oauth->process_callback({state => $login_state, iss => 'https://auth.example.com', code => $get_code->()});
is($session->{scopes}, ['atproto'], 'original session has only the originally-granted scope');
my $original_access_token = $session->{access_token};
my $redirect_url = $oauth->start_scope_upgrade($session, ['transition:generic']);
like($redirect_url, qr{^https://auth\.example\.com});
my $upgrade_state = $store->last_state_for_issuer('https://auth.example.com');
isnt($upgrade_state, $login_state, 'the upgrade uses its own fresh state/PAR request');
my $upgrade_request = $store->get_auth_request($upgrade_state);
is($upgrade_request->{upgrade_session_id}, $login_state, 'the auth request carries the original session_id through the round trip');
is([sort @{$upgrade_request->{scopes}}], ['atproto', 'transition:generic'], 'PAR requested the union of scopes, not just the new one');
my $upgraded = $oauth->process_callback({state => $upgrade_state, iss => 'https://auth.example.com', code => $get_code->()});
is($upgraded->{session_id}, $login_state, 'lands back on the original session_id, not a new one');
is([sort @{$upgraded->{scopes}}], ['atproto', 'transition:generic'], 'scopes merged, not replaced');
isnt($upgraded->{access_token}, $original_access_token, 'a genuinely new token pair from the upgraded authorization');
my $stored = $store->get_session('did:plc:testuser', $login_state);
( run in 0.528 second using v1.01-cache-2.11-cpan-8dfa8b56332 )