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 )