2021-10-16 11:01:59 -04:00
|
|
|
<?php
|
2021-10-06 18:05:26 -04:00
|
|
|
|
2023-05-17 12:56:55 -04:00
|
|
|
namespace BookStack\Access\Oidc;
|
2021-10-16 11:01:59 -04:00
|
|
|
|
2023-05-17 12:56:55 -04:00
|
|
|
use BookStack\Access\GroupSyncService;
|
|
|
|
use BookStack\Access\LoginService;
|
|
|
|
use BookStack\Access\RegistrationService;
|
2021-10-06 18:05:26 -04:00
|
|
|
use BookStack\Exceptions\JsonDebugException;
|
|
|
|
use BookStack\Exceptions\StoppedAuthenticationException;
|
|
|
|
use BookStack\Exceptions\UserRegistrationException;
|
2023-04-27 18:40:14 -04:00
|
|
|
use BookStack\Facades\Theme;
|
2023-09-08 09:16:09 -04:00
|
|
|
use BookStack\Http\HttpRequestService;
|
2023-04-27 18:40:14 -04:00
|
|
|
use BookStack\Theming\ThemeEvents;
|
2023-05-17 12:56:55 -04:00
|
|
|
use BookStack\Users\Models\User;
|
2021-10-12 18:00:52 -04:00
|
|
|
use Illuminate\Support\Facades\Cache;
|
2021-10-13 11:51:27 -04:00
|
|
|
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
|
2022-02-24 09:16:09 -05:00
|
|
|
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
2021-10-06 18:05:26 -04:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Class OpenIdConnectService
|
|
|
|
* Handles any app-specific OIDC tasks.
|
|
|
|
*/
|
2021-10-12 18:04:28 -04:00
|
|
|
class OidcService
|
2021-10-06 18:05:26 -04:00
|
|
|
{
|
2022-08-02 11:56:56 -04:00
|
|
|
public function __construct(
|
2023-04-27 18:40:14 -04:00
|
|
|
protected RegistrationService $registrationService,
|
|
|
|
protected LoginService $loginService,
|
2023-09-08 09:16:09 -04:00
|
|
|
protected HttpRequestService $http,
|
2023-04-27 18:40:14 -04:00
|
|
|
protected GroupSyncService $groupService
|
2022-08-29 12:46:41 -04:00
|
|
|
) {
|
2021-10-06 18:05:26 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Initiate an authorization flow.
|
2024-01-25 09:24:46 -05:00
|
|
|
* Provides back an authorize redirect URL, in addition to other
|
|
|
|
* details which may be required for the auth flow.
|
2021-10-16 11:01:59 -04:00
|
|
|
*
|
2022-02-24 09:16:09 -05:00
|
|
|
* @throws OidcException
|
2022-02-24 10:04:09 -05:00
|
|
|
*
|
|
|
|
* @return array{url: string, state: string}
|
2021-10-06 18:05:26 -04:00
|
|
|
*/
|
|
|
|
public function login(): array
|
|
|
|
{
|
2021-10-12 18:00:52 -04:00
|
|
|
$settings = $this->getProviderSettings();
|
|
|
|
$provider = $this->getProvider($settings);
|
2024-01-25 09:24:46 -05:00
|
|
|
|
|
|
|
$url = $provider->getAuthorizationUrl();
|
|
|
|
session()->put('oidc_pkce_code', $provider->getPkceCode() ?? '');
|
|
|
|
|
2021-10-06 18:05:26 -04:00
|
|
|
return [
|
2024-01-25 09:24:46 -05:00
|
|
|
'url' => $url,
|
2021-10-06 18:05:26 -04:00
|
|
|
'state' => $provider->getState(),
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Process the Authorization response from the authorization server and
|
2022-02-24 09:16:09 -05:00
|
|
|
* return the matching, or new if registration active, user matched to the
|
|
|
|
* authorization server. Throws if the user cannot be auth if not authenticated.
|
2021-10-16 11:01:59 -04:00
|
|
|
*
|
2022-02-24 09:16:09 -05:00
|
|
|
* @throws JsonDebugException
|
|
|
|
* @throws OidcException
|
|
|
|
* @throws StoppedAuthenticationException
|
|
|
|
* @throws IdentityProviderException
|
2021-10-06 18:05:26 -04:00
|
|
|
*/
|
2022-02-24 09:16:09 -05:00
|
|
|
public function processAuthorizeResponse(?string $authorizationCode): User
|
2021-10-06 18:05:26 -04:00
|
|
|
{
|
2021-10-12 18:00:52 -04:00
|
|
|
$settings = $this->getProviderSettings();
|
|
|
|
$provider = $this->getProvider($settings);
|
2021-10-06 18:05:26 -04:00
|
|
|
|
2024-01-25 09:24:46 -05:00
|
|
|
// Set PKCE code flashed at login
|
|
|
|
$pkceCode = session()->pull('oidc_pkce_code', '');
|
|
|
|
$provider->setPkceCode($pkceCode);
|
|
|
|
|
2021-10-06 18:05:26 -04:00
|
|
|
// Try to exchange authorization code for access token
|
|
|
|
$accessToken = $provider->getAccessToken('authorization_code', [
|
|
|
|
'code' => $authorizationCode,
|
|
|
|
]);
|
|
|
|
|
2021-10-12 18:00:52 -04:00
|
|
|
return $this->processAccessTokenCallback($accessToken, $settings);
|
2021-10-06 18:05:26 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2022-02-24 09:16:09 -05:00
|
|
|
* @throws OidcException
|
2021-10-06 18:05:26 -04:00
|
|
|
*/
|
2021-10-12 18:04:28 -04:00
|
|
|
protected function getProviderSettings(): OidcProviderSettings
|
2021-10-06 18:05:26 -04:00
|
|
|
{
|
2021-10-13 11:51:27 -04:00
|
|
|
$config = $this->config();
|
2021-10-12 18:04:28 -04:00
|
|
|
$settings = new OidcProviderSettings([
|
2021-10-16 11:01:59 -04:00
|
|
|
'issuer' => $config['issuer'],
|
|
|
|
'clientId' => $config['client_id'],
|
|
|
|
'clientSecret' => $config['client_secret'],
|
2021-10-13 11:51:27 -04:00
|
|
|
'authorizationEndpoint' => $config['authorization_endpoint'],
|
2021-10-16 11:01:59 -04:00
|
|
|
'tokenEndpoint' => $config['token_endpoint'],
|
2023-12-07 12:45:17 -05:00
|
|
|
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
|
Oidc: Properly query the UserInfo Endpoint
BooksStack's OIDC Client requests the 'profile' and 'email' scope values
in order to have access to the 'name', 'email', and other claims. It
looks for these claims in the ID Token that is returned along with the
Access Token.
However, the OIDC-core specification section 5.4 [1] only requires that
the Provider include those claims in the ID Token *if* an Access Token is
not also issued. If an Access Token is issued, the Provider can leave out
those claims from the ID Token, and the Client is supposed to obtain them
by submitting the Access Token to the UserInfo Endpoint.
So I suppose it's just good luck that the OIDC Providers that BookStack
has been tested with just so happen to also stick those claims in the ID
Token even though they don't have to. But others (in particular:
https://login.infomaniak.com) don't do so, and require fetching the
UserInfo Endpoint.)
A workaround is currently possible by having the user write a theme with a
ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE hook that fetches the UserInfo
Endpoint. This workaround isn't great, for a few reasons:
1. Asking the user to implement core parts of the OIDC protocol is silly.
2. The user either needs to re-fetch the .well-known/openid-configuration
file to discover the endpoint (adding yet another round-trip to each
login) or hard-code the endpoint, which is fragile.
3. The hook doesn't receive the HTTP client configuration.
So, have BookStack's OidcService fetch the UserInfo Endpoint and inject
those claims into the ID Token, if a UserInfo Endpoint is defined.
Two points about this:
- Injecting them into the ID Token's claims is the most obvious approach
given the current code structure; though I'm not sure it is the best
approach, perhaps it should instead fetch the user info in
processAuthorizationResponse() and pass that as an argument to
processAccessTokenCallback() which would then need a bit of
restructuring. But this made sense because it's also how the
ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE hook works.
- OIDC *requires* that a UserInfo Endpoint exists, so why bother with
that "if a UserInfo Endpoint is defined" bit? Simply out of an
abundance of caution that there's an existing BookStack user that is
relying on it not fetching the UserInfo Endpoint in order to work with
a non-compliant OIDC Provider.
[1]: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
2023-12-15 12:58:20 -05:00
|
|
|
'userinfoEndpoint' => $config['userinfo_endpoint'],
|
2021-10-12 18:00:52 -04:00
|
|
|
]);
|
|
|
|
|
|
|
|
// Use keys if configured
|
2021-10-13 11:51:27 -04:00
|
|
|
if (!empty($config['jwt_public_key'])) {
|
|
|
|
$settings->keys = [$config['jwt_public_key']];
|
2021-10-12 18:00:52 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
// Run discovery
|
2021-10-13 11:51:27 -04:00
|
|
|
if ($config['discover'] ?? false) {
|
2022-02-24 09:16:09 -05:00
|
|
|
try {
|
2023-09-08 09:16:09 -04:00
|
|
|
$settings->discoverFromIssuer($this->http->buildClient(5), Cache::store(null), 15);
|
2022-02-24 09:16:09 -05:00
|
|
|
} catch (OidcIssuerDiscoveryException $exception) {
|
|
|
|
throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
|
|
|
|
}
|
2021-10-12 18:00:52 -04:00
|
|
|
}
|
2021-10-06 18:05:26 -04:00
|
|
|
|
2023-12-06 11:41:50 -05:00
|
|
|
// Prevent use of RP-initiated logout if specifically disabled
|
2023-12-07 12:45:17 -05:00
|
|
|
// Or force use of a URL if specifically set.
|
2023-12-06 11:41:50 -05:00
|
|
|
if ($config['end_session_endpoint'] === false) {
|
|
|
|
$settings->endSessionEndpoint = null;
|
2023-12-07 12:45:17 -05:00
|
|
|
} else if (is_string($config['end_session_endpoint'])) {
|
|
|
|
$settings->endSessionEndpoint = $config['end_session_endpoint'];
|
2023-12-06 11:41:50 -05:00
|
|
|
}
|
|
|
|
|
2021-10-12 18:00:52 -04:00
|
|
|
$settings->validate();
|
|
|
|
|
|
|
|
return $settings;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Load the underlying OpenID Connect Provider.
|
|
|
|
*/
|
2021-10-12 18:04:28 -04:00
|
|
|
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
|
2021-10-12 18:00:52 -04:00
|
|
|
{
|
2024-04-16 10:19:51 -04:00
|
|
|
$provider = new OidcOAuthProvider([
|
|
|
|
...$settings->arrayForOAuthProvider(),
|
|
|
|
'redirectUri' => url('/oidc/callback'),
|
|
|
|
], [
|
2023-09-08 09:16:09 -04:00
|
|
|
'httpClient' => $this->http->buildClient(5),
|
2021-10-13 11:51:27 -04:00
|
|
|
'optionProvider' => new HttpBasicAuthOptionProvider(),
|
|
|
|
]);
|
2022-08-02 11:56:56 -04:00
|
|
|
|
|
|
|
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);
|
2022-08-29 12:46:41 -04:00
|
|
|
$scopeArr = array_map(fn (string $scope) => trim($scope), $scopeArr);
|
2022-08-02 11:56:56 -04:00
|
|
|
|
|
|
|
return array_filter($scopeArr);
|
2021-10-06 18:05:26 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Processes a received access token for a user. Login the user when
|
|
|
|
* they exist, optionally registering them automatically.
|
2021-10-16 11:01:59 -04:00
|
|
|
*
|
2022-02-24 09:16:09 -05:00
|
|
|
* @throws OidcException
|
2021-10-06 18:05:26 -04:00
|
|
|
* @throws JsonDebugException
|
|
|
|
* @throws StoppedAuthenticationException
|
|
|
|
*/
|
2021-10-12 18:04:28 -04:00
|
|
|
protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User
|
2021-10-06 18:05:26 -04:00
|
|
|
{
|
2021-10-11 14:05:16 -04:00
|
|
|
$idTokenText = $accessToken->getIdToken();
|
2021-10-12 18:04:28 -04:00
|
|
|
$idToken = new OidcIdToken(
|
2021-10-11 14:05:16 -04:00
|
|
|
$idTokenText,
|
2021-10-12 18:00:52 -04:00
|
|
|
$settings->issuer,
|
|
|
|
$settings->keys,
|
2021-10-11 14:05:16 -04:00
|
|
|
);
|
|
|
|
|
2023-12-06 08:49:53 -05:00
|
|
|
session()->put("oidc_id_token", $idTokenText);
|
2023-08-29 01:07:21 -04:00
|
|
|
|
2023-04-27 18:40:14 -04:00
|
|
|
$returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [
|
|
|
|
'access_token' => $accessToken->getToken(),
|
|
|
|
'expires_in' => $accessToken->getExpires(),
|
|
|
|
'refresh_token' => $accessToken->getRefreshToken(),
|
|
|
|
]);
|
|
|
|
|
|
|
|
if (!is_null($returnClaims)) {
|
|
|
|
$idToken->replaceClaims($returnClaims);
|
|
|
|
}
|
|
|
|
|
2021-10-13 11:51:27 -04:00
|
|
|
if ($this->config()['dump_user_details']) {
|
2021-10-12 11:48:54 -04:00
|
|
|
throw new JsonDebugException($idToken->getAllClaims());
|
2021-10-06 18:05:26 -04:00
|
|
|
}
|
|
|
|
|
2021-10-11 18:00:45 -04:00
|
|
|
try {
|
2021-10-12 18:00:52 -04:00
|
|
|
$idToken->validate($settings->clientId);
|
2021-10-12 18:04:28 -04:00
|
|
|
} catch (OidcInvalidTokenException $exception) {
|
2024-04-17 13:23:58 -04:00
|
|
|
throw new OidcException("ID token validation failed with error: {$exception->getMessage()}");
|
2024-04-16 13:10:32 -04:00
|
|
|
}
|
|
|
|
|
2024-04-17 13:23:58 -04:00
|
|
|
$userDetails = $this->getUserDetailsFromToken($idToken, $accessToken, $settings);
|
2024-04-16 13:10:32 -04:00
|
|
|
if (empty($userDetails->email)) {
|
2022-02-24 09:16:09 -05:00
|
|
|
throw new OidcException(trans('errors.oidc_no_email_address'));
|
2021-10-06 18:05:26 -04:00
|
|
|
}
|
2024-04-17 18:24:57 -04:00
|
|
|
if (empty($userDetails->name)) {
|
|
|
|
$userDetails->name = $userDetails->externalId;
|
|
|
|
}
|
2021-10-06 18:05:26 -04:00
|
|
|
|
2024-04-16 13:10:32 -04:00
|
|
|
$isLoggedIn = auth()->check();
|
2021-10-06 18:05:26 -04:00
|
|
|
if ($isLoggedIn) {
|
2022-02-24 09:16:09 -05:00
|
|
|
throw new OidcException(trans('errors.oidc_already_logged_in'));
|
2021-10-06 18:05:26 -04:00
|
|
|
}
|
|
|
|
|
2022-02-24 09:16:09 -05:00
|
|
|
try {
|
|
|
|
$user = $this->registrationService->findOrRegister(
|
2024-04-16 13:10:32 -04:00
|
|
|
$userDetails->name,
|
|
|
|
$userDetails->email,
|
|
|
|
$userDetails->externalId
|
2022-02-24 09:16:09 -05:00
|
|
|
);
|
|
|
|
} catch (UserRegistrationException $exception) {
|
|
|
|
throw new OidcException($exception->getMessage());
|
2021-10-06 18:05:26 -04:00
|
|
|
}
|
|
|
|
|
2022-08-02 11:56:56 -04:00
|
|
|
if ($this->shouldSyncGroups()) {
|
|
|
|
$detachExisting = $this->config()['remove_from_groups'];
|
2024-04-16 13:10:32 -04:00
|
|
|
$this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting);
|
2022-08-02 11:56:56 -04:00
|
|
|
}
|
|
|
|
|
2021-10-06 18:05:26 -04:00
|
|
|
$this->loginService->login($user, 'oidc');
|
2021-10-16 11:01:59 -04:00
|
|
|
|
2021-10-06 18:05:26 -04:00
|
|
|
return $user;
|
|
|
|
}
|
2021-10-13 11:51:27 -04:00
|
|
|
|
2024-04-17 13:23:58 -04:00
|
|
|
/**
|
|
|
|
* @throws OidcException
|
|
|
|
*/
|
|
|
|
protected function getUserDetailsFromToken(OidcIdToken $idToken, OidcAccessToken $accessToken, OidcProviderSettings $settings): OidcUserDetails
|
|
|
|
{
|
|
|
|
$userDetails = new OidcUserDetails();
|
|
|
|
$userDetails->populate(
|
|
|
|
$idToken,
|
|
|
|
$this->config()['external_id_claim'],
|
|
|
|
$this->config()['display_name_claims'] ?? '',
|
|
|
|
$this->config()['groups_claim'] ?? ''
|
|
|
|
);
|
|
|
|
|
|
|
|
if (!$userDetails->isFullyPopulated($this->shouldSyncGroups()) && !empty($settings->userinfoEndpoint)) {
|
|
|
|
$provider = $this->getProvider($settings);
|
|
|
|
$request = $provider->getAuthenticatedRequest('GET', $settings->userinfoEndpoint, $accessToken->getToken());
|
2024-04-19 09:12:27 -04:00
|
|
|
$response = new OidcUserinfoResponse(
|
|
|
|
$provider->getResponse($request),
|
|
|
|
$settings->issuer,
|
|
|
|
$settings->keys,
|
|
|
|
);
|
2024-04-17 13:23:58 -04:00
|
|
|
|
|
|
|
try {
|
|
|
|
$response->validate($idToken->getClaim('sub'));
|
|
|
|
} catch (OidcInvalidTokenException $exception) {
|
|
|
|
throw new OidcException("Userinfo endpoint response validation failed with error: {$exception->getMessage()}");
|
|
|
|
}
|
|
|
|
|
|
|
|
$userDetails->populate(
|
|
|
|
$response,
|
|
|
|
$this->config()['external_id_claim'],
|
|
|
|
$this->config()['display_name_claims'] ?? '',
|
|
|
|
$this->config()['groups_claim'] ?? ''
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return $userDetails;
|
|
|
|
}
|
|
|
|
|
2021-10-13 11:51:27 -04:00
|
|
|
/**
|
|
|
|
* Get the OIDC config from the application.
|
|
|
|
*/
|
|
|
|
protected function config(): array
|
|
|
|
{
|
|
|
|
return config('oidc');
|
|
|
|
}
|
2022-08-02 11:56:56 -04:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Check if groups should be synced.
|
|
|
|
*/
|
|
|
|
protected function shouldSyncGroups(): bool
|
|
|
|
{
|
|
|
|
return $this->config()['user_to_groups'] !== false;
|
|
|
|
}
|
2023-08-29 01:07:21 -04:00
|
|
|
|
|
|
|
/**
|
2023-12-06 08:49:53 -05:00
|
|
|
* Start the RP-initiated logout flow if active, otherwise start a standard logout flow.
|
|
|
|
* Returns a post-app-logout redirect URL.
|
|
|
|
* Reference: https://openid.net/specs/openid-connect-rpinitiated-1_0.html
|
2023-12-06 11:41:50 -05:00
|
|
|
* @throws OidcException
|
2023-08-29 01:07:21 -04:00
|
|
|
*/
|
2023-12-06 08:49:53 -05:00
|
|
|
public function logout(): string
|
|
|
|
{
|
|
|
|
$oidcToken = session()->pull("oidc_id_token");
|
|
|
|
$defaultLogoutUrl = url($this->loginService->logout());
|
2023-12-06 11:41:50 -05:00
|
|
|
$oidcSettings = $this->getProviderSettings();
|
|
|
|
|
|
|
|
if (!$oidcSettings->endSessionEndpoint) {
|
|
|
|
return $defaultLogoutUrl;
|
|
|
|
}
|
|
|
|
|
2023-12-06 08:49:53 -05:00
|
|
|
$endpointParams = [
|
|
|
|
'id_token_hint' => $oidcToken,
|
|
|
|
'post_logout_redirect_uri' => $defaultLogoutUrl,
|
|
|
|
];
|
2023-08-29 01:07:21 -04:00
|
|
|
|
2023-12-07 12:45:17 -05:00
|
|
|
$joiner = str_contains($oidcSettings->endSessionEndpoint, '?') ? '&' : '?';
|
|
|
|
|
|
|
|
return $oidcSettings->endSessionEndpoint . $joiner . http_build_query($endpointParams);
|
2023-08-29 01:07:21 -04:00
|
|
|
}
|
2021-10-06 18:05:26 -04:00
|
|
|
}
|