3 namespace BookStack\Auth\Access\Oidc;
6 use BookStack\Auth\Access\GroupSyncService;
7 use BookStack\Auth\Access\LoginService;
8 use BookStack\Auth\Access\RegistrationService;
9 use BookStack\Auth\User;
10 use BookStack\Exceptions\JsonDebugException;
11 use BookStack\Exceptions\StoppedAuthenticationException;
12 use BookStack\Exceptions\UserRegistrationException;
14 use Illuminate\Support\Arr;
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;
23 * Class OpenIdConnectService
24 * Handles any app-specific OIDC tasks.
28 protected RegistrationService $registrationService;
29 protected LoginService $loginService;
30 protected HttpClient $httpClient;
31 protected GroupSyncService $groupService;
34 * OpenIdService constructor.
36 public function __construct(
37 RegistrationService $registrationService,
38 LoginService $loginService,
39 HttpClient $httpClient,
40 GroupSyncService $groupService
42 $this->registrationService = $registrationService;
43 $this->loginService = $loginService;
44 $this->httpClient = $httpClient;
45 $this->groupService = $groupService;
49 * Initiate an authorization flow.
51 * @throws OidcException
53 * @return array{url: string, state: string}
55 public function login(): array
57 $settings = $this->getProviderSettings();
58 $provider = $this->getProvider($settings);
61 'url' => $provider->getAuthorizationUrl(),
62 'state' => $provider->getState(),
67 * Process the Authorization response from the authorization server and
68 * return the matching, or new if registration active, user matched to the
69 * authorization server. Throws if the user cannot be auth if not authenticated.
71 * @throws JsonDebugException
72 * @throws OidcException
73 * @throws StoppedAuthenticationException
74 * @throws IdentityProviderException
76 public function processAuthorizeResponse(?string $authorizationCode): User
78 $settings = $this->getProviderSettings();
79 $provider = $this->getProvider($settings);
81 // Try to exchange authorization code for access token
82 $accessToken = $provider->getAccessToken('authorization_code', [
83 'code' => $authorizationCode,
86 return $this->processAccessTokenCallback($accessToken, $settings);
90 * @throws OidcException
92 protected function getProviderSettings(): OidcProviderSettings
94 $config = $this->config();
95 $settings = new OidcProviderSettings([
96 'issuer' => $config['issuer'],
97 'clientId' => $config['client_id'],
98 'clientSecret' => $config['client_secret'],
99 'redirectUri' => url('/oidc/callback'),
100 'authorizationEndpoint' => $config['authorization_endpoint'],
101 'tokenEndpoint' => $config['token_endpoint'],
104 // Use keys if configured
105 if (!empty($config['jwt_public_key'])) {
106 $settings->keys = [$config['jwt_public_key']];
110 if ($config['discover'] ?? false) {
112 $settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
113 } catch (OidcIssuerDiscoveryException $exception) {
114 throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
118 $settings->validate();
124 * Load the underlying OpenID Connect Provider.
126 protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
128 $provider = new OidcOAuthProvider($settings->arrayForProvider(), [
129 'httpClient' => $this->httpClient,
130 'optionProvider' => new HttpBasicAuthOptionProvider(),
133 foreach ($this->getAdditionalScopes() as $scope) {
134 $provider->addScope($scope);
141 * Get any user-defined addition/custom scopes to apply to the authentication request.
145 protected function getAdditionalScopes(): array
147 $scopeConfig = $this->config()['additional_scopes'] ?: '';
149 $scopeArr = explode(',', $scopeConfig);
150 $scopeArr = array_map(fn (string $scope) => trim($scope), $scopeArr);
152 return array_filter($scopeArr);
156 * Calculate the display name.
158 protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
160 $displayNameAttr = $this->config()['display_name_claims'];
163 foreach ($displayNameAttr as $dnAttr) {
164 $dnComponent = $token->getClaim($dnAttr) ?? '';
165 if ($dnComponent !== '') {
166 $displayName[] = $dnComponent;
170 if (count($displayName) == 0) {
171 $displayName[] = $defaultValue;
174 return implode(' ', $displayName);
178 * Extract the assigned groups from the id token.
182 protected function getUserGroups(OidcIdToken $token): array
184 $groupsAttr = $this->config()['group_attribute'];
185 if (empty($groupsAttr)) {
189 $groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
190 if (!is_array($groupsList)) {
194 return array_values(array_filter($groupsList, function ($val) {
195 return is_string($val);
200 * Extract the details of a user from an ID token.
202 * @return array{name: string, email: string, external_id: string, groups: string[]}
204 protected function getUserDetails(OidcIdToken $token): array
206 $id = $token->getClaim('sub');
209 'external_id' => $id,
210 'email' => $token->getClaim('email'),
211 'name' => $this->getUserDisplayName($token, $id),
212 'groups' => $this->getUserGroups($token),
217 * Processes a received access token for a user. Login the user when
218 * they exist, optionally registering them automatically.
220 * @throws OidcException
221 * @throws JsonDebugException
222 * @throws StoppedAuthenticationException
224 protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User
226 $idTokenText = $accessToken->getIdToken();
227 $idToken = new OidcIdToken(
233 if ($this->config()['dump_user_details']) {
234 throw new JsonDebugException($idToken->getAllClaims());
238 $idToken->validate($settings->clientId);
239 } catch (OidcInvalidTokenException $exception) {
240 throw new OidcException("ID token validate failed with error: {$exception->getMessage()}");
243 $userDetails = $this->getUserDetails($idToken);
244 $isLoggedIn = auth()->check();
246 if (empty($userDetails['email'])) {
247 throw new OidcException(trans('errors.oidc_no_email_address'));
251 throw new OidcException(trans('errors.oidc_already_logged_in'));
255 $user = $this->registrationService->findOrRegister(
256 $userDetails['name'],
257 $userDetails['email'],
258 $userDetails['external_id']
260 } catch (UserRegistrationException $exception) {
261 throw new OidcException($exception->getMessage());
264 if ($this->shouldSyncGroups()) {
265 $groups = $userDetails['groups'];
266 $detachExisting = $this->config()['remove_from_groups'];
267 $this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
270 $this->loginService->login($user, 'oidc');
276 * Get the OIDC config from the application.
278 protected function config(): array
280 return config('oidc');
284 * Check if groups should be synced.
286 protected function shouldSyncGroups(): bool
288 return $this->config()['user_to_groups'] !== false;