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