1 <?php namespace BookStack\Auth\Access;
3 use BookStack\Auth\User;
4 use BookStack\Exceptions\JsonDebugException;
5 use BookStack\Exceptions\OpenIdException;
6 use BookStack\Exceptions\UserRegistrationException;
8 use Lcobucci\JWT\Signer\Rsa\Sha256;
9 use Lcobucci\JWT\Token;
10 use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
11 use OpenIDConnectClient\AccessToken;
12 use OpenIDConnectClient\Exception\InvalidTokenException;
13 use OpenIDConnectClient\OpenIDConnectProvider;
17 * Handles any app-specific OpenId tasks.
19 class OpenIdService extends ExternalAuthService
24 * OpenIdService constructor.
26 public function __construct(RegistrationService $registrationService, User $user)
28 parent::__construct($registrationService, $user);
30 $this->config = config('oidc');
34 * Initiate an authorization flow.
37 public function login(): array
39 $provider = $this->getProvider();
41 'url' => $provider->getAuthorizationUrl(),
42 'state' => $provider->getState(),
47 * Initiate a logout flow.
49 public function logout(): array
51 $this->actionLogout();
55 return ['url' => $url, 'id' => $id];
59 * Refresh the currently logged in user.
62 public function refresh(): bool
64 // Retrieve access token for current session
65 $json = session()->get('openid_token');
67 // If no access token was found, reject the refresh
69 $this->actionLogout();
73 $accessToken = new AccessToken(json_decode($json, true) ?? []);
75 // If the token is not expired, refreshing isn't necessary
76 if ($this->isUnexpired($accessToken)) {
80 // Try to obtain refreshed access token
82 $newAccessToken = $this->refreshAccessToken($accessToken);
83 } catch (Exception $e) {
84 // Log out if an unknown problem arises
85 $this->actionLogout();
89 // If a token was obtained, update the access token, otherwise log out
90 if ($newAccessToken !== null) {
91 session()->put('openid_token', json_encode($newAccessToken));
94 $this->actionLogout();
100 * Check whether an access token or OpenID token isn't expired.
102 protected function isUnexpired(AccessToken $accessToken): bool
104 $idToken = $accessToken->getIdToken();
106 $accessTokenUnexpired = $accessToken->getExpires() && !$accessToken->hasExpired();
107 $idTokenUnexpired = !$idToken || !$idToken->isExpired();
109 return $accessTokenUnexpired && $idTokenUnexpired;
113 * Generate an updated access token, through the associated refresh token.
116 protected function refreshAccessToken(AccessToken $accessToken): ?AccessToken
118 // If no refresh token available, abort
119 if ($accessToken->getRefreshToken() === null) {
123 // ID token or access token is expired, we refresh it using the refresh token
125 return $this->getProvider()->getAccessToken('refresh_token', [
126 'refresh_token' => $accessToken->getRefreshToken(),
128 } catch (IdentityProviderException $e) {
135 * Process the Authorization response from the authorization server and
136 * return the matching, or new if registration active, user matched to
137 * the authorization server.
138 * Returns null if not authenticated.
140 * @throws InvalidTokenException
142 public function processAuthorizeResponse(?string $authorizationCode): ?User
144 $provider = $this->getProvider();
146 // Try to exchange authorization code for access token
147 $accessToken = $provider->getAccessToken('authorization_code', [
148 'code' => $authorizationCode,
151 return $this->processAccessTokenCallback($accessToken);
155 * Do the required actions to log a user out.
157 protected function actionLogout()
160 session()->invalidate();
164 * Load the underlying OpenID Connect Provider.
166 protected function getProvider(): OpenIDConnectProvider
170 'clientId' => $this->config['client_id'],
171 'clientSecret' => $this->config['client_secret'],
172 'idTokenIssuer' => $this->config['issuer'],
173 'redirectUri' => url('/openid/redirect'),
174 'urlAuthorize' => $this->config['authorization_endpoint'],
175 'urlAccessToken' => $this->config['token_endpoint'],
176 'urlResourceOwnerDetails' => null,
177 'publicKey' => $this->config['jwt_public_key'],
178 'scopes' => 'profile email',
183 'signer' => new Sha256(),
186 return new OpenIDConnectProvider($settings, $services);
190 * Calculate the display name
192 protected function getUserDisplayName(Token $token, string $defaultValue): string
194 $displayNameAttr = $this->config['display_name_claims'];
197 foreach ($displayNameAttr as $dnAttr) {
198 $dnComponent = $token->claims()->get($dnAttr, '');
199 if ($dnComponent !== '') {
200 $displayName[] = $dnComponent;
204 if (count($displayName) == 0) {
205 $displayName[] = $defaultValue;
208 return implode(' ', $displayName);;
212 * Extract the details of a user from an ID token.
214 protected function getUserDetails(Token $token): array
216 $id = $token->claims()->get('sub');
218 'external_id' => $id,
219 'email' => $token->claims()->get('email'),
220 'name' => $this->getUserDisplayName($token, $id),
225 * Processes a received access token for a user. Login the user when
226 * they exist, optionally registering them automatically.
227 * @throws OpenIdException
228 * @throws JsonDebugException
229 * @throws UserRegistrationException
231 public function processAccessTokenCallback(AccessToken $accessToken): User
233 $userDetails = $this->getUserDetails($accessToken->getIdToken());
234 $isLoggedIn = auth()->check();
236 if ($this->config['dump_user_details']) {
237 throw new JsonDebugException($accessToken->jsonSerialize());
240 if ($userDetails['email'] === null) {
241 throw new OpenIdException(trans('errors.openid_no_email_address'));
245 throw new OpenIdException(trans('errors.openid_already_logged_in'), '/login');
248 $user = $this->getOrRegisterUser($userDetails);
249 if ($user === null) {
250 throw new OpenIdException(trans('errors.openid_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
253 auth()->login($user);
254 session()->put('openid_token', json_encode($accessToken));