<?php

namespace Tests\Auth;

use BookStack\Users\Models\Role;
use BookStack\Users\Models\User;
use Tests\TestCase;

class Saml2Test extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        // Set default config for SAML2
        config()->set([
            'auth.method'                                   => 'saml2',
            'auth.defaults.guard'                           => 'saml2',
            'saml2.name'                                    => 'SingleSignOn-Testing',
            'saml2.email_attribute'                         => 'email',
            'saml2.display_name_attributes'                 => ['first_name', 'last_name'],
            'saml2.external_id_attribute'                   => 'uid',
            'saml2.user_to_groups'                          => false,
            'saml2.group_attribute'                         => 'user_groups',
            'saml2.remove_from_groups'                      => false,
            'saml2.onelogin_overrides'                      => null,
            'saml2.onelogin.idp.entityId'                   => 'http://saml.local/saml2/idp/metadata.php',
            'saml2.onelogin.idp.singleSignOnService.url'    => 'http://saml.local/saml2/idp/SSOService.php',
            'saml2.onelogin.idp.singleLogoutService.url'    => 'http://saml.local/saml2/idp/SingleLogoutService.php',
            'saml2.autoload_from_metadata'                  => false,
            'saml2.onelogin.idp.x509cert'                   => $this->testCert,
            'saml2.onelogin.debug'                          => false,
            'saml2.onelogin.security.requestedAuthnContext' => true,
        ]);
    }

    public function test_metadata_endpoint_displays_xml_as_expected()
    {
        $req = $this->get('/saml2/metadata');
        $req->assertHeader('Content-Type', 'text/xml; charset=UTF-8');
        $req->assertSee('md:EntityDescriptor');
        $req->assertSee(url('/saml2/acs'));
    }

    public function test_metadata_endpoint_loads_when_autoloading_with_bad_url_set()
    {
        config()->set([
            'saml2.autoload_from_metadata' => true,
            'saml2.onelogin.idp.entityId' => 'http://192.168.1.1:9292',
            'saml2.onelogin.idp.singleSignOnService.url' => null,
        ]);

        $req = $this->get('/saml2/metadata');
        $req->assertOk();
        $req->assertHeader('Content-Type', 'text/xml; charset=UTF-8');
        $req->assertSee('md:EntityDescriptor');
    }

    public function test_onelogin_overrides_functions_as_expected()
    {
        $json = '{"sp": {"assertionConsumerService": {"url": "https://example.com/super-cats"}}, "contactPerson": {"technical": {"givenName": "Barry Scott", "emailAddress": "barry@example.com"}}}';
        config()->set(['saml2.onelogin_overrides' => $json]);

        $req = $this->get('/saml2/metadata');
        $req->assertSee('https://example.com/super-cats');
        $req->assertSee('md:ContactPerson');
        $req->assertSee('<md:GivenName>Barry Scott</md:GivenName>', false);
    }

    public function test_login_option_shows_on_login_page()
    {
        $req = $this->get('/login');
        $req->assertSeeText('SingleSignOn-Testing');
        $this->withHtml($req)->assertElementExists('form[action$="/saml2/login"][method=POST] button');
    }

    public function test_login()
    {
        $req = $this->post('/saml2/login');
        $redirect = $req->headers->get('location');
        $this->assertStringStartsWith('http://saml.local/saml2/idp/SSOService.php', $redirect, 'Login redirects to SSO location');

        config()->set(['saml2.onelogin.strict' => false]);
        $this->assertFalse($this->isAuthenticated());

        $acsPost = $this->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
        $redirect = $acsPost->headers->get('Location');
        $acsId = explode('?id=', $redirect)[1];
        $this->assertTrue(strlen($acsId) > 12);

        $this->assertStringContainsString('/saml2/acs?id=', $redirect);
        $this->assertTrue(cache()->has('saml2_acs:' . $acsId));

        $acsGet = $this->get($redirect);
        $acsGet->assertRedirect('/');
        $this->assertFalse(cache()->has('saml2_acs:' . $acsId));

        $this->assertTrue($this->isAuthenticated());
        $this->assertDatabaseHas('users', [
            'email'            => 'user@example.com',
            'external_auth_id' => 'user',
            'email_confirmed'  => false,
            'name'             => 'Barry Scott',
        ]);
    }

    public function test_acs_process_id_randomly_generated()
    {
        $acsPost = $this->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
        $redirectA = $acsPost->headers->get('Location');

        $acsPost = $this->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
        $redirectB = $acsPost->headers->get('Location');

        $this->assertFalse($redirectA === $redirectB);
    }

    public function test_process_acs_endpoint_cant_be_called_with_invalid_id()
    {
        $resp = $this->get('/saml2/acs');
        $resp->assertRedirect('/login');
        $this->followRedirects($resp)->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization');

        $resp = $this->get('/saml2/acs?id=abc123');
        $resp->assertRedirect('/login');
        $this->followRedirects($resp)->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
    }

    public function test_group_role_sync_on_login()
    {
        config()->set([
            'saml2.onelogin.strict'    => false,
            'saml2.user_to_groups'     => true,
            'saml2.remove_from_groups' => false,
        ]);

        $memberRole = Role::factory()->create(['external_auth_id' => 'member']);
        $adminRole = Role::getSystemRole('admin');

        $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
        $user = User::query()->where('external_auth_id', '=', 'user')->first();

        $userRoleIds = $user->roles()->pluck('id');
        $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role');
        $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role');
    }

    public function test_group_role_sync_removal_option_works_as_expected()
    {
        config()->set([
            'saml2.onelogin.strict'    => false,
            'saml2.user_to_groups'     => true,
            'saml2.remove_from_groups' => true,
        ]);

        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
        $user = User::query()->where('external_auth_id', '=', 'user')->first();

        $randomRole = Role::factory()->create(['external_auth_id' => 'random']);
        $user->attachRole($randomRole);
        $this->assertContains($randomRole->id, $user->roles()->pluck('id'));

        auth()->logout();
        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
        $this->assertNotContains($randomRole->id, $user->roles()->pluck('id'));
    }

    public function test_logout_link_directs_to_saml_path()
    {
        config()->set([
            'saml2.onelogin.strict' => false,
        ]);

        $resp = $this->actingAs($this->users->editor())->get('/');
        $this->withHtml($resp)->assertElementContains('form[action$="/saml2/logout"] button', 'Logout');
    }

    public function test_logout_sls_flow()
    {
        config()->set([
            'saml2.onelogin.strict' => false,
        ]);

        $handleLogoutResponse = function () {
            $this->assertFalse($this->isAuthenticated());

            $req = $this->get('/saml2/sls');
            $req->assertRedirect('/');
            $this->assertFalse($this->isAuthenticated());
        };

        $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);

        $req = $this->post('/saml2/logout');
        $redirect = $req->headers->get('location');
        $this->assertStringStartsWith('http://saml.local/saml2/idp/SingleLogoutService.php', $redirect);
        $sloData = $this->parseSamlDataFromUrl($redirect, 'SAMLRequest');
        $this->assertStringContainsString('<samlp:SessionIndex>_4fe7c0d1572d64b27f930aa6f236a6f42e930901cc</samlp:SessionIndex>', $sloData);

        $this->withGet(['SAMLResponse' => $this->sloResponseData], $handleLogoutResponse);
    }

    public function test_logout_sls_flow_when_sls_not_configured()
    {
        config()->set([
            'saml2.onelogin.strict'                      => false,
            'saml2.onelogin.idp.singleLogoutService.url' => null,
        ]);

        $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
        $this->assertTrue($this->isAuthenticated());

        $req = $this->post('/saml2/logout');
        $req->assertRedirect('/');
        $this->assertFalse($this->isAuthenticated());
    }

    public function test_logout_sls_flow_logs_user_out_before_redirect()
    {
        config()->set([
            'saml2.onelogin.strict' => false,
        ]);

        $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
        $this->assertTrue($this->isAuthenticated());

        $req = $this->post('/saml2/logout');
        $redirect = $req->headers->get('location');
        $this->assertStringStartsWith('http://saml.local/saml2/idp/SingleLogoutService.php', $redirect);
        $this->assertFalse($this->isAuthenticated());
    }

    public function test_logout_sls_request_redirect_prevents_auto_login_when_enabled()
    {
        config()->set([
            'saml2.onelogin.strict' => false,
            'auth.auto_initiate' => true,
            'services.google.client_id' => false,
            'services.github.client_id' => false,
        ]);

        $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);

        $req = $this->post('/saml2/logout');
        $redirect = $req->headers->get('location');
        $this->assertStringContainsString(urlencode(url('/login?prevent_auto_init=true')), $redirect);
    }

    public function test_logout_sls_response_endpoint_redirect_prevents_auto_login_when_enabled()
    {
        config()->set([
            'saml2.onelogin.strict' => false,
            'auth.auto_initiate' => true,
            'services.google.client_id' => false,
            'services.github.client_id' => false,
        ]);

        $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);

        $this->withGet(['SAMLResponse' => $this->sloResponseData], function () {
            $req = $this->get('/saml2/sls');
            $redirect = $req->headers->get('location');
            $this->assertEquals(url('/login?prevent_auto_init=true'), $redirect);
        });
    }

    public function test_dump_user_details_option_works()
    {
        config()->set([
            'saml2.onelogin.strict'   => false,
            'saml2.dump_user_details' => true,
        ]);

        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
        $acsPost->assertJsonStructure([
            'id_from_idp',
            'attrs_from_idp'      => [],
            'attrs_after_parsing' => [],
        ]);
    }

    public function test_dump_user_details_response_contains_parsed_group_data_if_groups_enabled()
    {
        config()->set([
            'saml2.onelogin.strict'   => false,
            'saml2.dump_user_details' => true,
            'saml2.user_to_groups'    => true,
        ]);

        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
        $acsPost->assertJson([
            'attrs_after_parsing' => [
                'groups' => ['member', 'admin'],
            ]
        ]);
    }

    public function test_saml_routes_are_only_active_if_saml_enabled()
    {
        config()->set(['auth.method' => 'standard']);
        $getRoutes = ['/metadata', '/sls'];
        foreach ($getRoutes as $route) {
            $req = $this->get('/saml2' . $route);
            $this->assertPermissionError($req);
        }

        $postRoutes = ['/login', '/acs', '/logout'];
        foreach ($postRoutes as $route) {
            $req = $this->post('/saml2' . $route);
            $this->assertPermissionError($req);
        }
    }

    public function test_forgot_password_routes_inaccessible()
    {
        $resp = $this->get('/password/email');
        $this->assertPermissionError($resp);

        $resp = $this->post('/password/email');
        $this->assertPermissionError($resp);

        $resp = $this->get('/password/reset/abc123');
        $this->assertPermissionError($resp);

        $resp = $this->post('/password/reset');
        $this->assertPermissionError($resp);
    }

    public function test_standard_login_routes_inaccessible()
    {
        $resp = $this->post('/login');
        $this->assertPermissionError($resp);

        $resp = $this->post('/logout');
        $this->assertPermissionError($resp);
    }

    public function test_user_invite_routes_inaccessible()
    {
        $resp = $this->get('/register/invite/abc123');
        $this->assertPermissionError($resp);

        $resp = $this->post('/register/invite/abc123');
        $this->assertPermissionError($resp);
    }

    public function test_user_register_routes_inaccessible()
    {
        $resp = $this->get('/register');
        $this->assertPermissionError($resp);

        $resp = $this->post('/register');
        $this->assertPermissionError($resp);
    }

    public function test_email_domain_restriction_active_on_new_saml_login()
    {
        $this->setSettings([
            'registration-restrict' => 'testing.com',
        ]);
        config()->set([
            'saml2.onelogin.strict' => false,
        ]);

        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
        $acsPost->assertSeeText('That email domain does not have access to this application');
        $this->assertFalse(auth()->check());
        $this->assertDatabaseMissing('users', ['email' => 'user@example.com']);
    }

    public function test_group_sync_functions_when_email_confirmation_required()
    {
        setting()->put('registration-confirmation', 'true');
        config()->set([
            'saml2.onelogin.strict'    => false,
            'saml2.user_to_groups'     => true,
            'saml2.remove_from_groups' => false,
        ]);

        $memberRole = Role::factory()->create(['external_auth_id' => 'member']);
        $adminRole = Role::getSystemRole('admin');

        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);

        $this->assertEquals('http://localhost/register/confirm', url()->current());
        $acsPost->assertSee('Please check your email and click the confirmation button to access BookStack.');
        /** @var User $user */
        $user = User::query()->where('external_auth_id', '=', 'user')->first();

        $userRoleIds = $user->roles()->pluck('id');
        $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role');
        $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role');
        $this->assertFalse(boolval($user->email_confirmed), 'User email remains unconfirmed');

        $this->assertNull(auth()->user());
        $homeGet = $this->get('/');
        $homeGet->assertRedirect('/login');
    }

    public function test_login_where_existing_non_saml_user_shows_warning()
    {
        $this->post('/saml2/login');
        config()->set(['saml2.onelogin.strict' => false]);

        // Make the user pre-existing in DB with different auth_id
        User::query()->forceCreate([
            'email'            => 'user@example.com',
            'external_auth_id' => 'old_system_user_id',
            'email_confirmed'  => false,
            'name'             => 'Barry Scott',
        ]);

        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
        $this->assertFalse($this->isAuthenticated());
        $this->assertDatabaseHas('users', [
            'email'            => 'user@example.com',
            'external_auth_id' => 'old_system_user_id',
        ]);

        $acsPost->assertSee('A user with the email user@example.com already exists but with different credentials');
    }

    public function test_login_request_contains_expected_default_authncontext()
    {
        $authReq = $this->getAuthnRequest();
        $this->assertStringContainsString('samlp:RequestedAuthnContext Comparison="exact"', $authReq);
        $this->assertStringContainsString('<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>', $authReq);
    }

    public function test_false_idp_authncontext_option_does_not_pass_authncontext_in_saml_request()
    {
        config()->set(['saml2.onelogin.security.requestedAuthnContext' => false]);
        $authReq = $this->getAuthnRequest();
        $this->assertStringNotContainsString('samlp:RequestedAuthnContext', $authReq);
        $this->assertStringNotContainsString('<saml:AuthnContextClassRef>', $authReq);
    }

    public function test_array_idp_authncontext_option_passes_value_as_authncontextclassref_in_request()
    {
        config()->set(['saml2.onelogin.security.requestedAuthnContext' => ['urn:federation:authentication:windows', 'urn:federation:authentication:linux']]);
        $authReq = $this->getAuthnRequest();
        $this->assertStringContainsString('samlp:RequestedAuthnContext', $authReq);
        $this->assertStringContainsString('<saml:AuthnContextClassRef>urn:federation:authentication:windows</saml:AuthnContextClassRef>', $authReq);
        $this->assertStringContainsString('<saml:AuthnContextClassRef>urn:federation:authentication:linux</saml:AuthnContextClassRef>', $authReq);
    }

    protected function getAuthnRequest(): string
    {
        $req = $this->post('/saml2/login');
        $location = $req->headers->get('Location');
        return $this->parseSamlDataFromUrl($location, 'SAMLRequest');
    }

    protected function parseSamlDataFromUrl(string $url, string $paramName): string
    {
        $query = explode('?', $url)[1];
        $params = [];
        parse_str($query, $params);

        return gzinflate(base64_decode($params[$paramName]));
    }

    protected function withGet(array $options, callable $callback)
    {
        return $this->withGlobal($_GET, $options, $callback);
    }

    protected function withGlobal(array &$global, array $options, callable $callback)
    {
        $original = [];
        foreach ($options as $key => $val) {
            $original[$key] = $global[$key] ?? null;
            $global[$key] = $val;
        }

        $callback();

        foreach ($options as $key => $val) {
            $val = $original[$key];
            if ($val) {
                $global[$key] = $val;
            } else {
                unset($global[$key]);
            }
        }
    }

    /**
     * The post data for a callback for single-sign-in.
     * Provides the following attributes:
     * array:5 [
     * "uid" => array:1 [
     * 0 => "user"
     * ]
     * "first_name" => array:1 [
     * 0 => "Barry"
     * ]
     * "last_name" => array:1 [
     * 0 => "Scott"
     * ]
     * "email" => array:1 [
     * 0 => "user@example.com"
     * ]
     * "user_groups" => array:2 [
     * 0 => "member"
     * 1 => "admin"
     * ]
     * ].
     */
    protected string $acsPostData = ' ';

    protected string $sloResponseData = 'fZHRa8IwEMb/lZJ3bdJa04a2MOYYglOY4sNe5JKms9gmpZfC/vxF3ZjC8OXgLvl938ddjtC1vVjZTzu6d429NaiDr641KC5PBRkHIyxgg8JAp1E4JbZPbysRTanoB+ussi25QR4TgKgH11hDguWiIIeawTxOaK1iPYt5XcczHUlJeVRlMklBJjOuM1qDVCTY6wE9WRAv5HHEUS8NOjDOjyjLJoxNGN+xVESpSNgHCRYaXWPAXaijc70IQ2ntyUPqNG2tgjY8Z45CbNFLmt8V7GxBNuuX1eZ1uT7EcZJKAE4TJhXPaMxlVlFffPKKJnXE5ryusoiU+VlMXJIN5Y/feXRn1VR92GkHFTiY9sc+D2+p/HqRrQM34n33bCsd7KEd9eMd4+W32I5KaUQSlleHP9Hwv6uX3w==';

    protected string $testCert = 'MIIEazCCAtOgAwIBAgIUe7a088Cnr4izmrnBEnx5q3HTMvYwDQYJKoZIhvcNAQELBQAwRTELMAkGA1UEBhMCR0IxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0xOTExMTYxMjE3MTVaFw0yOTExMTUxMjE3MTVaMEUxCzAJBgNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQDzLe9FfdyplTxHp4SuQ9gQtZT3t+SDfvEL72ppCfFZw7+B5s5B/T73aXpoQ3S53pGI1RIWCge2iCUQ2tzm27aSNH0iu9aJYcUQZ/RITqd0ayyDks1NA2PT3TW6t3m7KV5re4P0Nb+YDeuyHdkz+jcMtpn8CmBoT0H+skha0hiqINkjkRPiHvLHVGp+tHUEA/I6mN4aB/UExSTLs79NsLUfteqqxe9+tvdUaToyDPrhPFjONs+9NKCkzIC6vcv7J6AtuKG6nET+zB9yOWgtGYQifXqQA2y5dL81BB0q5uMaBLS2pq3aPPjzU2F3+EysjySWTnCkfk7C5SsCXRu8Q+U95tunpNfwf5olE6Was48NMM+PwV7iCNMPkNzllq6PCiM+P8DrMSczzUZZQUSv6dSwPCo+YSVimEM0Og3XJTiNhQ5ANlaIn66Kw5gfoBfuiXmyIKiSDyAiDYmFaf4395wWwLkTR+cw8WfjaHswKZTomn1MR3OJsY2UJ0eRBYM+YSsCAwEAAaNTMFEwHQYDVR0OBBYEFImp2CYCGfcb7w91H/cShTCkXwR/MB8GA1UdIwQYMBaAFImp2CYCGfcb7w91H/cShTCkXwR/MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggGBAA+g/C7uL9ln+W+qBknLW81kojYflgPK1I1MHIwnMvl/ZTHX4dRXKDrk7KcUq1KjqajNV66f1cakp03IijBiO0Xi1gXUZYLoCiNGUyyp9XloiIy9Xw2PiWnrw0+yZyvVssbehXXYJl4RihBjBWul9R4wMYLOUSJDe2WxcUBhJnxyNRs+P0xLSQX6B2n6nxoDko4p07s8ZKXQkeiZ2iwFdTxzRkGjthMUv704nzsVGBT0DCPtfSaO5KJZW1rCs3yiMthnBxq4qEDOQJFIl+/LD71KbB9vZcW5JuavzBFmkKGNro/6G1I7el46IR4wijTyNFCYUuD9dtignNmpWtN8OW+ptiL/jtTySWukjys0s+vLn83CVvjB0dJtVAIYOgXFdIuii66gczwwM/LGiOExJn0dTNzsJ/IYhpxL4FBEuP0pskY0o0aUlJ2LS2j+wSQTRKsBgMjyrUrekle2ODStStn3eabjIx0/FHlpFr0jNIm/oMP7kwjtUX4zaNe47QI4Gg==';
}
