use BookStack\Activity\ActivityType;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
+use BookStack\Uploads\UserAvatars;
use BookStack\Users\Models\Role;
use BookStack\Users\Models\User;
use GuzzleHttp\Psr7\Response;
'oidc.discover' => false,
'oidc.dump_user_details' => false,
'oidc.additional_scopes' => '',
+ 'odic.fetch_avatar' => false,
'oidc.user_to_groups' => false,
'oidc.groups_claim' => 'group',
'oidc.remove_from_groups' => false,
]);
}
+ public function test_user_avatar_fetched_from_picture_on_first_login_if_enabled()
+ {
+ config()->set(['oidc.fetch_avatar' => true]);
+
+ $this->runLogin([
+ 'picture' => 'https://example.com/my-avatar.jpg',
+ ], [
+ new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData())
+ ]);
+
+ $this->assertNotNull($user);
+
+ $this->assertTrue($user->avatar()->exists());
+ }
+
+ public function test_user_avatar_fetched_for_existing_user_when_no_avatar_already_assigned()
+ {
+ config()->set(['oidc.fetch_avatar' => true]);
+ $editor = $this->users->editor();
+ $editor->external_auth_id = 'benny509';
+ $editor->save();
+
+ $this->assertFalse($editor->avatar()->exists());
+
+ $this->runLogin([
+ 'picture' => 'https://example.com/my-avatar.jpg',
+ 'sub' => 'benny509',
+ ], [
+ new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData())
+ ]);
+
+ $editor->refresh();
+ $this->assertTrue($editor->avatar()->exists());
+ }
+
+ public function test_user_avatar_not_fetched_if_image_data_format_unknown()
+ {
+ config()->set(['oidc.fetch_avatar' => true]);
+
+ $this->runLogin([
+ 'picture' => 'https://example.com/my-avatar.jpg',
+ ], [
+ new Response(200, ['Content-Type' => 'image/jpeg'], str_repeat('abc123', 5))
+ ]);
+
+ $this->assertNotNull($user);
+
+ $this->assertFalse($user->avatar()->exists());
+ }
+
+ public function test_user_avatar_not_fetched_when_avatar_already_assigned()
+ {
+ config()->set(['oidc.fetch_avatar' => true]);
+ $editor = $this->users->editor();
+ $editor->external_auth_id = 'benny509';
+ $editor->save();
+
+ $avatars = $this->app->make(UserAvatars::class);
+ $originalImageData = $this->files->pngImageData();
+ $avatars->assignToUserFromExistingData($editor, $originalImageData, 'png');
+
+ $this->runLogin([
+ 'picture' => 'https://example.com/my-avatar.jpg',
+ 'sub' => 'benny509',
+ ], [
+ new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData())
+ ]);
+
+ $editor->refresh();
+ $newAvatarData = file_get_contents($this->files->relativeToFullPath($editor->avatar->path));
+ $this->assertEquals($originalImageData, $newAvatarData);
+ }
+
+ public function test_user_avatar_fetch_follows_up_to_three_redirects()
+ {
+ config()->set(['oidc.fetch_avatar' => true]);
+
+ $logger = $this->withTestLogger();
+
+ $this->runLogin([
+ 'picture' => 'https://example.com/my-avatar.jpg',
+ ], [
+ new Response(302, ['Location' => 'https://example.com/a']),
+ new Response(302, ['Location' => 'https://example.com/b']),
+ new Response(302, ['Location' => 'https://example.com/c']),
+ new Response(302, ['Location' => 'https://example.com/d']),
+ ]);
+
+ $this->assertFalse($user->avatar()->exists());
+
+ $this->assertStringContainsString('"Failed to fetch image, max redirect limit of 3 tries reached. Last fetched URL: https://example.com/c"', $logger->getRecords()[0]->formatted);
+ }
+
public function test_login_group_sync()
{
config()->set([
$this->assertTrue($user->hasRole($roleA->id));
}
+ public function test_userinfo_endpoint_response_with_complex_json_content_type_handled()
+ {
+ $userinfoResponseData = [
+ 'sub' => OidcJwtHelper::defaultPayload()['sub'],
+ 'name' => 'Barry',
+ ];
+ $userinfoResponse = new Response(200, ['Content-Type' => 'Application/Json ; charset=utf-8'], json_encode($userinfoResponseData));
+ $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
+ $resp->assertRedirect('/');
+
+ $user = User::where('email', OidcJwtHelper::defaultPayload()['email'])->first();
+ $this->assertEquals('Barry', $user->name);
+ }
+
+ public function test_userinfo_endpoint_jwks_response_handled()
+ {
+ $userinfoResponseData = OidcJwtHelper::idToken(['name' => 'Barry Jwks']);
+ $userinfoResponse = new Response(200, ['Content-Type' => 'application/jwt'], $userinfoResponseData);
+
+ $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
+ $resp->assertRedirect('/');
+
+ $user = User::where('email', OidcJwtHelper::defaultPayload()['email'])->first();
+ $this->assertEquals('Barry Jwks', $user->name);
+ }
+
+ public function test_userinfo_endpoint_jwks_response_returning_no_sub_throws()
+ {
+ $userinfoResponseData = OidcJwtHelper::idToken(['sub' => null]);
+ $userinfoResponse = new Response(200, ['Content-Type' => 'application/jwt'], $userinfoResponseData);
+
+ $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
+ $resp->assertRedirect('/login');
+ $this->assertSessionError('Userinfo endpoint response validation failed with error: No valid subject value found in userinfo data');
+ }
+
+ public function test_userinfo_endpoint_jwks_response_returning_non_matching_sub_throws()
+ {
+ $userinfoResponseData = OidcJwtHelper::idToken(['sub' => 'zzz123']);
+ $userinfoResponse = new Response(200, ['Content-Type' => 'application/jwt'], $userinfoResponseData);
+
+ $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
+ $resp->assertRedirect('/login');
+ $this->assertSessionError('Userinfo endpoint response validation failed with error: Subject value provided in the userinfo endpoint does not match the provided ID token value');
+ }
+
+ public function test_userinfo_endpoint_jwks_response_with_invalid_signature_throws()
+ {
+ $userinfoResponseData = OidcJwtHelper::idToken();
+ $exploded = explode('.', $userinfoResponseData);
+ $exploded[2] = base64_encode(base64_decode($exploded[2]) . 'ABC');
+ $userinfoResponse = new Response(200, ['Content-Type' => 'application/jwt'], implode('.', $exploded));
+
+ $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
+ $resp->assertRedirect('/login');
+ $this->assertSessionError('Userinfo endpoint response validation failed with error: Token signature could not be validated using the provided keys');
+ }
+
+ public function test_userinfo_endpoint_jwks_response_with_invalid_signature_alg_throws()
+ {
+ $userinfoResponseData = OidcJwtHelper::idToken([], ['alg' => 'ZZ512']);
+ $userinfoResponse = new Response(200, ['Content-Type' => 'application/jwt'], $userinfoResponseData);
+
+ $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
+ $resp->assertRedirect('/login');
+ $this->assertSessionError('Userinfo endpoint response validation failed with error: Only RS256 signature validation is supported. Token reports using ZZ512');
+ }
+
+ public function test_userinfo_endpoint_response_with_invalid_content_type_throws()
+ {
+ $userinfoResponse = new Response(200, ['Content-Type' => 'application/beans'], json_encode(OidcJwtHelper::defaultPayload()));
+ $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
+ $resp->assertRedirect('/login');
+ $this->assertSessionError('Userinfo endpoint response validation failed with error: No valid subject value found in userinfo data');
+ }
+
+ public function test_userinfo_endpoint_not_called_if_empty_groups_array_provided_in_id_token()
+ {
+ config()->set([
+ 'oidc.user_to_groups' => true,
+ 'oidc.groups_claim' => 'groups',
+ 'oidc.remove_from_groups' => false,
+ ]);
+
+ $this->post('/oidc/login');
+ $state = session()->get('oidc_state');
+ $client = $this->mockHttpClient([$this->getMockAuthorizationResponse([
+ 'groups' => [],
+ ])]);
+
+ $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
+ $resp->assertRedirect('/');
+ $this->assertEquals(1, $client->requestCount());
+ $this->assertTrue(auth()->check());
+ }
+
protected function withAutodiscovery(): void
{
config()->set([