X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/263384cf99864ebdb0408fd4e478f783aa487c1a..refs/pull/3918/head:/tests/Auth/OidcTest.php diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php index cf04080fc..db1f87bd5 100644 --- a/tests/Auth/OidcTest.php +++ b/tests/Auth/OidcTest.php @@ -1,21 +1,22 @@ -keyFilePath, OidcJwtHelper::publicPemKey()); config()->set([ - 'auth.method' => 'oidc', - 'auth.defaults.guard' => 'oidc', - 'oidc.name' => 'SingleSignOn-Testing', - 'oidc.display_name_claims' => ['name'], - 'oidc.client_id' => OidcJwtHelper::defaultClientId(), - 'oidc.client_secret' => 'testpass', - 'oidc.jwt_public_key' => $this->keyFilePath, - 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), + 'auth.method' => 'oidc', + 'auth.defaults.guard' => 'oidc', + 'oidc.name' => 'SingleSignOn-Testing', + 'oidc.display_name_claims' => ['name'], + 'oidc.client_id' => OidcJwtHelper::defaultClientId(), + 'oidc.client_secret' => 'testpass', + 'oidc.jwt_public_key' => $this->keyFilePath, + 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), 'oidc.authorization_endpoint' => 'https://oidc.local/auth', - 'oidc.token_endpoint' => 'https://oidc.local/token', - 'oidc.discover' => false, - 'oidc.dump_user_details' => false, + 'oidc.token_endpoint' => 'https://oidc.local/token', + 'oidc.discover' => false, + 'oidc.dump_user_details' => false, + 'oidc.additional_scopes' => '', + 'oidc.user_to_groups' => false, + 'oidc.groups_claim' => 'group', + 'oidc.remove_from_groups' => false, ]); } - public function tearDown(): void + protected function tearDown(): void { parent::tearDown(); if (file_exists($this->keyFilePath)) { @@ -52,7 +57,7 @@ class OidcTest extends TestCase { $req = $this->get('/login'); $req->assertSeeText('SingleSignOn-Testing'); - $req->assertElementExists('form[action$="/oidc/login"][method=POST] button'); + $this->withHtml($req)->assertElementExists('form[action$="/oidc/login"][method=POST] button'); } public function test_oidc_routes_are_only_active_if_oidc_enabled() @@ -89,7 +94,7 @@ class OidcTest extends TestCase public function test_logout_route_functions() { $this->actingAs($this->getEditor()); - $this->get('/logout'); + $this->post('/logout'); $this->assertFalse(auth()->check()); } @@ -131,7 +136,7 @@ class OidcTest extends TestCase $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([ 'email' => 'benny@example.com', - 'sub' => 'benny1010101' + 'sub' => 'benny1010101', ])]); // Callback from auth provider @@ -148,18 +153,28 @@ class OidcTest extends TestCase $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody()); $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody()); - $this->assertTrue(auth()->check()); $this->assertDatabaseHas('users', [ - 'email' => 'benny@example.com', + 'email' => 'benny@example.com', 'external_auth_id' => 'benny1010101', - 'email_confirmed' => false, + 'email_confirmed' => false, ]); $user = User::query()->where('email', '=', 'benny@example.com')->first(); $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott"); } + public function test_login_uses_custom_additional_scopes_if_defined() + { + config()->set([ + 'oidc.additional_scopes' => 'groups, badgers', + ]); + + $redirect = $this->post('/oidc/login')->headers->get('location'); + + $this->assertStringContainsString('scope=openid%20profile%20email%20groups%20badgers', $redirect); + } + public function test_callback_fails_if_no_state_present_or_matching() { $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124'); @@ -176,15 +191,15 @@ class OidcTest extends TestCase $resp = $this->runLogin([ 'email' => 'benny@example.com', - 'sub' => 'benny505' + 'sub' => 'benny505', ]); $resp->assertStatus(200); $resp->assertJson([ 'email' => 'benny@example.com', - 'sub' => 'benny505', - "iss" => OidcJwtHelper::defaultIssuer(), - "aud" => OidcJwtHelper::defaultClientId(), + 'sub' => 'benny505', + 'iss' => OidcJwtHelper::defaultIssuer(), + 'aud' => OidcJwtHelper::defaultClientId(), ]); $this->assertFalse(auth()->check()); } @@ -193,7 +208,7 @@ class OidcTest extends TestCase { $this->runLogin([ 'email' => '', - 'sub' => 'benny505' + 'sub' => 'benny505', ]); $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system'); @@ -205,7 +220,7 @@ class OidcTest extends TestCase $this->runLogin([ 'email' => 'benny@example.com', - 'sub' => 'benny505' + 'sub' => 'benny505', ]); $this->assertSessionError('Already logged in'); @@ -221,7 +236,7 @@ class OidcTest extends TestCase $this->runLogin([ 'email' => 'benny@example.com', - 'sub' => 'benny505' + 'sub' => 'benny505', ]); $this->assertTrue(auth()->check()); @@ -236,22 +251,24 @@ class OidcTest extends TestCase $this->assertFalse(auth()->check()); - $this->runLogin([ + $resp = $this->runLogin([ 'email' => $editor->email, - 'sub' => 'benny505' + 'sub' => 'benny505', ]); + $resp = $this->followRedirects($resp); - $this->assertSessionError('A user with the email ' . $editor->email . ' already exists but with different credentials.'); + $resp->assertSeeText('A user with the email ' . $editor->email . ' already exists but with different credentials.'); $this->assertFalse(auth()->check()); } public function test_auth_login_with_invalid_token_fails() { - $this->runLogin([ + $resp = $this->runLogin([ 'sub' => null, ]); + $resp = $this->followRedirects($resp); - $this->assertSessionError('ID token validate failed with error: Missing token subject value'); + $resp->assertSeeText('ID token validate failed with error: Missing token subject value'); $this->assertFalse(auth()->check()); } @@ -287,9 +304,9 @@ class OidcTest extends TestCase new Response(404, [], 'Not found'), ]); - $this->runLogin(); + $resp = $this->followRedirects($this->runLogin()); $this->assertFalse(auth()->check()); - $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + $resp->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); } public function test_autodiscovery_calls_are_cached() @@ -300,7 +317,7 @@ class OidcTest extends TestCase $this->getAutoDiscoveryResponse(), $this->getJwksResponse(), $this->getAutoDiscoveryResponse([ - 'issuer' => 'https://auto.example.com' + 'issuer' => 'https://auto.example.com', ]), $this->getJwksResponse(), ]); @@ -318,14 +335,123 @@ class OidcTest extends TestCase $this->assertCount(4, $transactions); } + public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_alg_property() + { + $this->withAutodiscovery(); + + $keyArray = OidcJwtHelper::publicJwkKeyArray(); + unset($keyArray['alg']); + + $this->mockHttpClient([ + $this->getAutoDiscoveryResponse(), + new Response(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache, no-store', + 'Pragma' => 'no-cache', + ], json_encode([ + 'keys' => [ + $keyArray, + ], + ])), + ]); + + $this->assertFalse(auth()->check()); + $this->runLogin(); + $this->assertTrue(auth()->check()); + } + + public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_use_property() + { + // Based on reading the OIDC discovery spec: + // > This contains the signing key(s) the RP uses to validate signatures from the OP. The JWK Set MAY also + // > contain the Server's encryption key(s), which are used by RPs to encrypt requests to the Server. When + // > both signing and encryption keys are made available, a use (Key Use) parameter value is REQUIRED for all + // > keys in the referenced JWK Set to indicate each key's intended usage. + // We can assume that keys without use are intended for signing. + $this->withAutodiscovery(); + + $keyArray = OidcJwtHelper::publicJwkKeyArray(); + unset($keyArray['use']); + + $this->mockHttpClient([ + $this->getAutoDiscoveryResponse(), + new Response(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache, no-store', + 'Pragma' => 'no-cache', + ], json_encode([ + 'keys' => [ + $keyArray, + ], + ])), + ]); + + $this->assertFalse(auth()->check()); + $this->runLogin(); + $this->assertTrue(auth()->check()); + } + + public function test_login_group_sync() + { + config()->set([ + 'oidc.user_to_groups' => true, + 'oidc.groups_claim' => 'groups', + 'oidc.remove_from_groups' => false, + ]); + $roleA = Role::factory()->create(['display_name' => 'Wizards']); + $roleB = Role::factory()->create(['display_name' => 'ZooFolks', 'external_auth_id' => 'zookeepers']); + $roleC = Role::factory()->create(['display_name' => 'Another Role']); + + $resp = $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny1010101', + 'groups' => ['Wizards', 'Zookeepers'], + ]); + $resp->assertRedirect('/'); + + /** @var User $user */ + $user = User::query()->where('email', '=', 'benny@example.com')->first(); + + $this->assertTrue($user->hasRole($roleA->id)); + $this->assertTrue($user->hasRole($roleB->id)); + $this->assertFalse($user->hasRole($roleC->id)); + } + + public function test_login_group_sync_with_nested_groups_in_token() + { + config()->set([ + 'oidc.user_to_groups' => true, + 'oidc.groups_claim' => 'my.custom.groups.attr', + 'oidc.remove_from_groups' => false, + ]); + $roleA = Role::factory()->create(['display_name' => 'Wizards']); + + $resp = $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny1010101', + 'my' => [ + 'custom' => [ + 'groups' => [ + 'attr' => ['Wizards'], + ], + ], + ], + ]); + $resp->assertRedirect('/'); + + /** @var User $user */ + $user = User::query()->where('email', '=', 'benny@example.com')->first(); + $this->assertTrue($user->hasRole($roleA->id)); + } + protected function withAutodiscovery() { config()->set([ - 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), - 'oidc.discover' => true, + 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), + 'oidc.discover' => true, 'oidc.authorization_endpoint' => null, - 'oidc.token_endpoint' => null, - 'oidc.jwt_public_key' => null, + 'oidc.token_endpoint' => null, + 'oidc.jwt_public_key' => null, ]); } @@ -341,41 +467,41 @@ class OidcTest extends TestCase protected function getAutoDiscoveryResponse($responseOverrides = []): Response { return new Response(200, [ - 'Content-Type' => 'application/json', + 'Content-Type' => 'application/json', 'Cache-Control' => 'no-cache, no-store', - 'Pragma' => 'no-cache' + 'Pragma' => 'no-cache', ], json_encode(array_merge([ - 'token_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/token', + 'token_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/token', 'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize', - 'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys', - 'issuer' => OidcJwtHelper::defaultIssuer() + 'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys', + 'issuer' => OidcJwtHelper::defaultIssuer(), ], $responseOverrides))); } protected function getJwksResponse(): Response { return new Response(200, [ - 'Content-Type' => 'application/json', + 'Content-Type' => 'application/json', 'Cache-Control' => 'no-cache, no-store', - 'Pragma' => 'no-cache' + 'Pragma' => 'no-cache', ], json_encode([ 'keys' => [ - OidcJwtHelper::publicJwkKeyArray() - ] + OidcJwtHelper::publicJwkKeyArray(), + ], ])); } protected function getMockAuthorizationResponse($claimOverrides = []): Response { return new Response(200, [ - 'Content-Type' => 'application/json', + 'Content-Type' => 'application/json', 'Cache-Control' => 'no-cache, no-store', - 'Pragma' => 'no-cache' + 'Pragma' => 'no-cache', ], json_encode([ 'access_token' => 'abc123', - 'token_type' => 'Bearer', - 'expires_in' => 3600, - 'id_token' => OidcJwtHelper::idToken($claimOverrides) + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'id_token' => OidcJwtHelper::idToken($claimOverrides), ])); } }