]> BookStack Code Mirror - bookstack/blob - app/Auth/Access/Oidc/OidcService.php
Added OIDC group sync functionality
[bookstack] / app / Auth / Access / Oidc / OidcService.php
1 <?php
2
3 namespace BookStack\Auth\Access\Oidc;
4
5 use BookStack\Auth\Access\GroupSyncService;
6 use Illuminate\Support\Arr;
7 use function auth;
8 use BookStack\Auth\Access\LoginService;
9 use BookStack\Auth\Access\RegistrationService;
10 use BookStack\Auth\User;
11 use BookStack\Exceptions\JsonDebugException;
12 use BookStack\Exceptions\StoppedAuthenticationException;
13 use BookStack\Exceptions\UserRegistrationException;
14 use function config;
15 use Illuminate\Support\Facades\Cache;
16 use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
17 use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
18 use Psr\Http\Client\ClientInterface as HttpClient;
19 use function trans;
20 use function url;
21
22 /**
23  * Class OpenIdConnectService
24  * Handles any app-specific OIDC tasks.
25  */
26 class OidcService
27 {
28     protected RegistrationService $registrationService;
29     protected LoginService $loginService;
30     protected HttpClient $httpClient;
31     protected GroupSyncService $groupService;
32
33     /**
34      * OpenIdService constructor.
35      */
36     public function __construct(
37         RegistrationService $registrationService,
38         LoginService        $loginService,
39         HttpClient          $httpClient,
40         GroupSyncService    $groupService
41     )
42     {
43         $this->registrationService = $registrationService;
44         $this->loginService = $loginService;
45         $this->httpClient = $httpClient;
46         $this->groupService = $groupService;
47     }
48
49     /**
50      * Initiate an authorization flow.
51      *
52      * @throws OidcException
53      *
54      * @return array{url: string, state: string}
55      */
56     public function login(): array
57     {
58         $settings = $this->getProviderSettings();
59         $provider = $this->getProvider($settings);
60
61         return [
62             'url'   => $provider->getAuthorizationUrl(),
63             'state' => $provider->getState(),
64         ];
65     }
66
67     /**
68      * Process the Authorization response from the authorization server and
69      * return the matching, or new if registration active, user matched to the
70      * authorization server. Throws if the user cannot be auth if not authenticated.
71      *
72      * @throws JsonDebugException
73      * @throws OidcException
74      * @throws StoppedAuthenticationException
75      * @throws IdentityProviderException
76      */
77     public function processAuthorizeResponse(?string $authorizationCode): User
78     {
79         $settings = $this->getProviderSettings();
80         $provider = $this->getProvider($settings);
81
82         // Try to exchange authorization code for access token
83         $accessToken = $provider->getAccessToken('authorization_code', [
84             'code' => $authorizationCode,
85         ]);
86
87         return $this->processAccessTokenCallback($accessToken, $settings);
88     }
89
90     /**
91      * @throws OidcException
92      */
93     protected function getProviderSettings(): OidcProviderSettings
94     {
95         $config = $this->config();
96         $settings = new OidcProviderSettings([
97             'issuer'                => $config['issuer'],
98             'clientId'              => $config['client_id'],
99             'clientSecret'          => $config['client_secret'],
100             'redirectUri'           => url('/oidc/callback'),
101             'authorizationEndpoint' => $config['authorization_endpoint'],
102             'tokenEndpoint'         => $config['token_endpoint'],
103         ]);
104
105         // Use keys if configured
106         if (!empty($config['jwt_public_key'])) {
107             $settings->keys = [$config['jwt_public_key']];
108         }
109
110         // Run discovery
111         if ($config['discover'] ?? false) {
112             try {
113                 $settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
114             } catch (OidcIssuerDiscoveryException $exception) {
115                 throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
116             }
117         }
118
119         $settings->validate();
120
121         return $settings;
122     }
123
124     /**
125      * Load the underlying OpenID Connect Provider.
126      */
127     protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
128     {
129         $provider = new OidcOAuthProvider($settings->arrayForProvider(), [
130             'httpClient'     => $this->httpClient,
131             'optionProvider' => new HttpBasicAuthOptionProvider(),
132         ]);
133
134         foreach ($this->getAdditionalScopes() as $scope) {
135             $provider->addScope($scope);
136         }
137
138         return $provider;
139     }
140
141     /**
142      * Get any user-defined addition/custom scopes to apply to the authentication request.
143      *
144      * @return string[]
145      */
146     protected function getAdditionalScopes(): array
147     {
148         $scopeConfig = $this->config()['additional_scopes'] ?: '';
149
150         $scopeArr = explode(',', $scopeConfig);
151         $scopeArr = array_map(fn(string $scope) => trim($scope), $scopeArr);
152
153         return array_filter($scopeArr);
154     }
155
156     /**
157      * Calculate the display name.
158      */
159     protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
160     {
161         $displayNameAttr = $this->config()['display_name_claims'];
162
163         $displayName = [];
164         foreach ($displayNameAttr as $dnAttr) {
165             $dnComponent = $token->getClaim($dnAttr) ?? '';
166             if ($dnComponent !== '') {
167                 $displayName[] = $dnComponent;
168             }
169         }
170
171         if (count($displayName) == 0) {
172             $displayName[] = $defaultValue;
173         }
174
175         return implode(' ', $displayName);
176     }
177
178     /**
179      * Extract the assigned groups from the id token.
180      *
181      * @return string[]
182      */
183     protected function getUserGroups(OidcIdToken $token): array
184     {
185         $groupsAttr = $this->config()['group_attribute'];
186         if (empty($groupsAttr)) {
187             return [];
188         }
189
190         $groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
191         if (!is_array($groupsList)) {
192             return [];
193         }
194
195         return array_values(array_filter($groupsList, function($val) {
196             return is_string($val);
197         }));
198     }
199
200     /**
201      * Extract the details of a user from an ID token.
202      *
203      * @return array{name: string, email: string, external_id: string, groups: string[]}
204      */
205     protected function getUserDetails(OidcIdToken $token): array
206     {
207         $id = $token->getClaim('sub');
208
209         return [
210             'external_id' => $id,
211             'email'       => $token->getClaim('email'),
212             'name'        => $this->getUserDisplayName($token, $id),
213             'groups'      => $this->getUserGroups($token),
214         ];
215     }
216
217     /**
218      * Processes a received access token for a user. Login the user when
219      * they exist, optionally registering them automatically.
220      *
221      * @throws OidcException
222      * @throws JsonDebugException
223      * @throws StoppedAuthenticationException
224      */
225     protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User
226     {
227         $idTokenText = $accessToken->getIdToken();
228         $idToken = new OidcIdToken(
229             $idTokenText,
230             $settings->issuer,
231             $settings->keys,
232         );
233
234         if ($this->config()['dump_user_details']) {
235             throw new JsonDebugException($idToken->getAllClaims());
236         }
237
238         try {
239             $idToken->validate($settings->clientId);
240         } catch (OidcInvalidTokenException $exception) {
241             throw new OidcException("ID token validate failed with error: {$exception->getMessage()}");
242         }
243
244         $userDetails = $this->getUserDetails($idToken);
245         $isLoggedIn = auth()->check();
246
247         if (empty($userDetails['email'])) {
248             throw new OidcException(trans('errors.oidc_no_email_address'));
249         }
250
251         if ($isLoggedIn) {
252             throw new OidcException(trans('errors.oidc_already_logged_in'));
253         }
254
255         try {
256             $user = $this->registrationService->findOrRegister(
257                 $userDetails['name'],
258                 $userDetails['email'],
259                 $userDetails['external_id']
260             );
261         } catch (UserRegistrationException $exception) {
262             throw new OidcException($exception->getMessage());
263         }
264
265         if ($this->shouldSyncGroups()) {
266             $groups = $userDetails['groups'];
267             $detachExisting = $this->config()['remove_from_groups'];
268             $this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
269         }
270
271         $this->loginService->login($user, 'oidc');
272
273         return $user;
274     }
275
276     /**
277      * Get the OIDC config from the application.
278      */
279     protected function config(): array
280     {
281         return config('oidc');
282     }
283
284     /**
285      * Check if groups should be synced.
286      */
287     protected function shouldSyncGroups(): bool
288     {
289         return $this->config()['user_to_groups'] !== false;
290     }
291 }