]> BookStack Code Mirror - bookstack/commitdiff
Added OIDC group sync functionality 3616/head
authorDan Brown <redacted>
Tue, 2 Aug 2022 15:56:56 +0000 (16:56 +0100)
committerDan Brown <redacted>
Tue, 2 Aug 2022 15:56:56 +0000 (16:56 +0100)
Is generally aligned with out SAML2 group sync functionality, but for
OIDC based upon feedback in #3004.
Neeeded the tangental addition of being able to define custom scopes on
the initial auth request as some systems use this to provide additional
id token claims such as groups.

Includes tests to cover.
Tested live using Okta.

.env.example.complete
app/Auth/Access/Oidc/OidcOAuthProvider.php
app/Auth/Access/Oidc/OidcService.php
app/Config/oidc.php
tests/Auth/OidcTest.php

index 45b1c7a863969d0fabc07081a3a2ff87d7f17871..c097af4f664606e5edaf439a35a13f318a800bf6 100644 (file)
@@ -263,7 +263,11 @@ OIDC_ISSUER_DISCOVER=false
 OIDC_PUBLIC_KEY=null
 OIDC_AUTH_ENDPOINT=null
 OIDC_TOKEN_ENDPOINT=null
+OIDC_ADDITIONAL_SCOPES=null
 OIDC_DUMP_USER_DETAILS=false
+OIDC_USER_TO_GROUPS=false
+OIDC_GROUP_ATTRIBUTE=groups
+OIDC_REMOVE_FROM_GROUPS=false
 
 # Disable default third-party services such as Gravatar and Draw.IO
 # Service-specific options will override this option
index 9b9d0524c0d66d6ef3dc467e015d8c92cddcec5e..07bd980a35d05080fbf19de18c6be970506c6aef 100644 (file)
@@ -30,6 +30,11 @@ class OidcOAuthProvider extends AbstractProvider
      */
     protected $tokenEndpoint;
 
+    /**
+     * Scopes to use for the OIDC authorization call
+     */
+    protected array $scopes = ['openid', 'profile', 'email'];
+
     /**
      * Returns the base URL for authorizing a client.
      */
@@ -54,6 +59,15 @@ class OidcOAuthProvider extends AbstractProvider
         return '';
     }
 
+    /**
+     * Add an additional scope to this provider upon the default.
+     */
+    public function addScope(string $scope): void
+    {
+        $this->scopes[] = $scope;
+        $this->scopes = array_unique($this->scopes);
+    }
+
     /**
      * Returns the default scopes used by this provider.
      *
@@ -62,7 +76,7 @@ class OidcOAuthProvider extends AbstractProvider
      */
     protected function getDefaultScopes(): array
     {
-        return ['openid', 'profile', 'email'];
+        return $this->scopes;
     }
 
     /**
index eeacdb732357dc135075ef32827b3cfb2fafe74f..3443baaf619c1c6f8cc488f5d7f729417d4d3544 100644 (file)
@@ -2,6 +2,8 @@
 
 namespace BookStack\Auth\Access\Oidc;
 
+use BookStack\Auth\Access\GroupSyncService;
+use Illuminate\Support\Arr;
 use function auth;
 use BookStack\Auth\Access\LoginService;
 use BookStack\Auth\Access\RegistrationService;
@@ -26,15 +28,22 @@ class OidcService
     protected RegistrationService $registrationService;
     protected LoginService $loginService;
     protected HttpClient $httpClient;
+    protected GroupSyncService $groupService;
 
     /**
      * OpenIdService constructor.
      */
-    public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient)
+    public function __construct(
+        RegistrationService $registrationService,
+        LoginService        $loginService,
+        HttpClient          $httpClient,
+        GroupSyncService    $groupService
+    )
     {
         $this->registrationService = $registrationService;
         $this->loginService = $loginService;
         $this->httpClient = $httpClient;
+        $this->groupService = $groupService;
     }
 
     /**
@@ -117,10 +126,31 @@ class OidcService
      */
     protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
     {
-        return new OidcOAuthProvider($settings->arrayForProvider(), [
+        $provider = new OidcOAuthProvider($settings->arrayForProvider(), [
             'httpClient'     => $this->httpClient,
             'optionProvider' => new HttpBasicAuthOptionProvider(),
         ]);
+
+        foreach ($this->getAdditionalScopes() as $scope) {
+            $provider->addScope($scope);
+        }
+
+        return $provider;
+    }
+
+    /**
+     * Get any user-defined addition/custom scopes to apply to the authentication request.
+     *
+     * @return string[]
+     */
+    protected function getAdditionalScopes(): array
+    {
+        $scopeConfig = $this->config()['additional_scopes'] ?: '';
+
+        $scopeArr = explode(',', $scopeConfig);
+        $scopeArr = array_map(fn(string $scope) => trim($scope), $scopeArr);
+
+        return array_filter($scopeArr);
     }
 
     /**
@@ -145,10 +175,32 @@ class OidcService
         return implode(' ', $displayName);
     }
 
+    /**
+     * Extract the assigned groups from the id token.
+     *
+     * @return string[]
+     */
+    protected function getUserGroups(OidcIdToken $token): array
+    {
+        $groupsAttr = $this->config()['group_attribute'];
+        if (empty($groupsAttr)) {
+            return [];
+        }
+
+        $groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
+        if (!is_array($groupsList)) {
+            return [];
+        }
+
+        return array_values(array_filter($groupsList, function($val) {
+            return is_string($val);
+        }));
+    }
+
     /**
      * Extract the details of a user from an ID token.
      *
-     * @return array{name: string, email: string, external_id: string}
+     * @return array{name: string, email: string, external_id: string, groups: string[]}
      */
     protected function getUserDetails(OidcIdToken $token): array
     {
@@ -158,6 +210,7 @@ class OidcService
             'external_id' => $id,
             'email'       => $token->getClaim('email'),
             'name'        => $this->getUserDisplayName($token, $id),
+            'groups'      => $this->getUserGroups($token),
         ];
     }
 
@@ -209,6 +262,12 @@ class OidcService
             throw new OidcException($exception->getMessage());
         }
 
+        if ($this->shouldSyncGroups()) {
+            $groups = $userDetails['groups'];
+            $detachExisting = $this->config()['remove_from_groups'];
+            $this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
+        }
+
         $this->loginService->login($user, 'oidc');
 
         return $user;
@@ -221,4 +280,12 @@ class OidcService
     {
         return config('oidc');
     }
+
+    /**
+     * Check if groups should be synced.
+     */
+    protected function shouldSyncGroups(): bool
+    {
+        return $this->config()['user_to_groups'] !== false;
+    }
 }
index 842ac8af6b8ef236822fe50a9c11c8190627bff7..8a9dd3a87fb4393b60a7a10ec27f0d5d60e52103 100644 (file)
@@ -32,4 +32,16 @@ return [
     // OAuth2 endpoints.
     'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
     'token_endpoint'         => env('OIDC_TOKEN_ENDPOINT', null),
+
+    // Add extra scopes, upon those required, to the OIDC authentication request
+    // Multiple values can be provided comma seperated.
+    'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
+
+    // Group sync options
+    // Enable syncing, upon login, of OIDC groups to BookStack roles
+    'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
+    // Attribute, within a OIDC ID token, to find group names within
+    'group_attribute' => env('OIDC_GROUP_ATTRIBUTE', 'groups'),
+    // When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
+    'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false),
 ];
index aa2c99a36dbf92268ec77dfc45f834fe6e7c82d7..4215f6a541d5c03739626551d875592c54c2cfdc 100644 (file)
@@ -3,6 +3,7 @@
 namespace Tests\Auth;
 
 use BookStack\Actions\ActivityType;
+use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use GuzzleHttp\Psr7\Request;
 use GuzzleHttp\Psr7\Response;
@@ -37,6 +38,10 @@ class OidcTest extends TestCase
             'oidc.token_endpoint'         => 'https://oidc.local/token',
             'oidc.discover'               => false,
             'oidc.dump_user_details'      => false,
+            'oidc.additional_scopes'      => '',
+            'oidc.user_to_groups'         => false,
+            'oidc.group_attribute'        => 'group',
+            'oidc.remove_from_groups'     => false,
         ]);
     }
 
@@ -159,6 +164,17 @@ class OidcTest extends TestCase
         $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');
@@ -344,6 +360,59 @@ class OidcTest extends TestCase
         $this->assertTrue(auth()->check());
     }
 
+    public function test_login_group_sync()
+    {
+        config()->set([
+            'oidc.user_to_groups'     => true,
+            'oidc.group_attribute'    => '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'  => '[email protected]',
+            'sub'    => 'benny1010101',
+            'groups' => ['Wizards', 'Zookeepers']
+        ]);
+        $resp->assertRedirect('/');
+
+        /** @var User $user */
+        $user = User::query()->where('email', '=', '[email protected]')->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.group_attribute'    => 'my.custom.groups.attr',
+            'oidc.remove_from_groups' => false,
+        ]);
+        $roleA = Role::factory()->create(['display_name' => 'Wizards']);
+
+        $resp = $this->runLogin([
+            'email'  => '[email protected]',
+            'sub'    => 'benny1010101',
+            'my' => [
+                'custom' => [
+                    'groups' => [
+                        'attr' => ['Wizards']
+                    ]
+                ]
+            ]
+        ]);
+        $resp->assertRedirect('/');
+
+        /** @var User $user */
+        $user = User::query()->where('email', '=', '[email protected]')->first();
+        $this->assertTrue($user->hasRole($roleA->id));
+    }
+
     protected function withAutodiscovery()
     {
         config()->set([