1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-07-30 04:23:11 +03:00

Added OIDC basic autodiscovery support

This commit is contained in:
Dan Brown
2021-10-12 23:00:52 +01:00
parent 790723dfc5
commit 06a0d829c8
7 changed files with 325 additions and 15 deletions

View File

@ -0,0 +1,8 @@
<?php
namespace BookStack\Auth\Access\OpenIdConnect;
class IssuerDiscoveryException extends \Exception
{
}

View File

@ -0,0 +1,198 @@
<?php
namespace BookStack\Auth\Access\OpenIdConnect;
use GuzzleHttp\Psr7\Request;
use Illuminate\Contracts\Cache\Repository;
use InvalidArgumentException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
/**
* OpenIdConnectProviderSettings
* Acts as a DTO for settings used within the oidc request and token handling.
* Performs auto-discovery upon request.
*/
class OpenIdConnectProviderSettings
{
/**
* @var string
*/
public $issuer;
/**
* @var string
*/
public $clientId;
/**
* @var string
*/
public $clientSecret;
/**
* @var string
*/
public $redirectUri;
/**
* @var string
*/
public $authorizationEndpoint;
/**
* @var string
*/
public $tokenEndpoint;
/**
* @var string[]|array[]
*/
public $keys = [];
public function __construct(array $settings)
{
$this->applySettingsFromArray($settings);
$this->validateInitial();
}
/**
* Apply an array of settings to populate setting properties within this class.
*/
protected function applySettingsFromArray(array $settingsArray)
{
foreach ($settingsArray as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
}
/**
* Validate any core, required properties have been set.
* @throws InvalidArgumentException
*/
protected function validateInitial()
{
$required = ['clientId', 'clientSecret', 'redirectUri', 'issuer'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
}
}
if (strpos($this->issuer, 'https://') !== 0) {
throw new InvalidArgumentException("Issuer value must start with https://");
}
}
/**
* Perform a full validation on these settings.
* @throws InvalidArgumentException
*/
public function validate(): void
{
$this->validateInitial();
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
}
}
}
/**
* Discover and autoload settings from the configured issuer.
* @throws IssuerDiscoveryException
*/
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes)
{
try {
$cacheKey = 'oidc-discovery::' . $this->issuer;
$discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function() use ($httpClient) {
return $this->loadSettingsFromIssuerDiscovery($httpClient);
});
$this->applySettingsFromArray($discoveredSettings);
} catch (ClientExceptionInterface $exception) {
throw new IssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}");
}
}
/**
* @throws IssuerDiscoveryException
* @throws ClientExceptionInterface
*/
protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array
{
$issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration';
$request = new Request('GET', $issuerUrl);
$response = $httpClient->sendRequest($request);
$result = json_decode($response->getBody()->getContents(), true);
if (empty($result) || !is_array($result)) {
throw new IssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}");
}
if ($result['issuer'] !== $this->issuer) {
throw new IssuerDiscoveryException("Unexpected issuer value found on discovery response");
}
$discoveredSettings = [];
if (!empty($result['authorization_endpoint'])) {
$discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint'];
}
if (!empty($result['token_endpoint'])) {
$discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
}
if (!empty($result['jwks_uri'])) {
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
$discoveredSettings['keys'] = array_filter($keys);
}
return $discoveredSettings;
}
/**
* Filter the given JWK keys down to just those we support.
*/
protected function filterKeys(array $keys): array
{
return array_filter($keys, function(array $key) {
return $key['key'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256';
});
}
/**
* Return an array of jwks as PHP key=>value arrays.
* @throws ClientExceptionInterface
* @throws IssuerDiscoveryException
*/
protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array
{
$request = new Request('GET', $uri);
$response = $httpClient->sendRequest($request);
$result = json_decode($response->getBody()->getContents(), true);
if (empty($result) || !is_array($result) || !isset($result['keys'])) {
throw new IssuerDiscoveryException("Error reading keys from issuer jwks_uri");
}
return $result['keys'];
}
/**
* Get the settings needed by an OAuth provider, as a key=>value array.
*/
public function arrayForProvider(): array
{
$settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint'];
$settings = [];
foreach ($settingKeys as $setting) {
$settings[$setting] = $this->$setting;
}
return $settings;
}
}

View File

@ -8,6 +8,9 @@ use BookStack\Exceptions\OpenIdConnectException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use Exception;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Cache;
use Psr\Http\Client\ClientExceptionInterface;
use function auth;
use function config;
use function trans;
@ -39,7 +42,8 @@ class OpenIdConnectService
*/
public function login(): array
{
$provider = $this->getProvider();
$settings = $this->getProviderSettings();
$provider = $this->getProvider($settings);
return [
'url' => $provider->getAuthorizationUrl(),
'state' => $provider->getState(),
@ -52,34 +56,57 @@ class OpenIdConnectService
* the authorization server.
* Returns null if not authenticated.
* @throws Exception
* @throws ClientExceptionInterface
*/
public function processAuthorizeResponse(?string $authorizationCode): ?User
{
$provider = $this->getProvider();
$settings = $this->getProviderSettings();
$provider = $this->getProvider($settings);
// Try to exchange authorization code for access token
$accessToken = $provider->getAccessToken('authorization_code', [
'code' => $authorizationCode,
]);
return $this->processAccessTokenCallback($accessToken);
return $this->processAccessTokenCallback($accessToken, $settings);
}
/**
* Load the underlying OpenID Connect Provider.
* @throws IssuerDiscoveryException
* @throws ClientExceptionInterface
*/
protected function getProvider(): OpenIdConnectOAuthProvider
protected function getProviderSettings(): OpenIdConnectProviderSettings
{
// Setup settings
$settings = [
$settings = new OpenIdConnectProviderSettings([
'issuer' => $this->config['issuer'],
'clientId' => $this->config['client_id'],
'clientSecret' => $this->config['client_secret'],
'redirectUri' => url('/oidc/redirect'),
'authorizationEndpoint' => $this->config['authorization_endpoint'],
'tokenEndpoint' => $this->config['token_endpoint'],
];
]);
return new OpenIdConnectOAuthProvider($settings);
// Use keys if configured
if (!empty($this->config['jwt_public_key'])) {
$settings->keys = [$this->config['jwt_public_key']];
}
// Run discovery
if ($this->config['discover'] ?? false) {
$settings->discoverFromIssuer(new Client(['timeout' => 3]), Cache::store(null), 15);
}
$settings->validate();
return $settings;
}
/**
* Load the underlying OpenID Connect Provider.
*/
protected function getProvider(OpenIdConnectProviderSettings $settings): OpenIdConnectOAuthProvider
{
return new OpenIdConnectOAuthProvider($settings->arrayForProvider());
}
/**
@ -126,13 +153,13 @@ class OpenIdConnectService
* @throws UserRegistrationException
* @throws StoppedAuthenticationException
*/
protected function processAccessTokenCallback(OpenIdConnectAccessToken $accessToken): User
protected function processAccessTokenCallback(OpenIdConnectAccessToken $accessToken, OpenIdConnectProviderSettings $settings): User
{
$idTokenText = $accessToken->getIdToken();
$idToken = new OpenIdConnectIdToken(
$idTokenText,
$this->config['issuer'],
[$this->config['jwt_public_key']]
$settings->issuer,
$settings->keys,
);
if ($this->config['dump_user_details']) {
@ -140,7 +167,7 @@ class OpenIdConnectService
}
try {
$idToken->validate($this->config['client_id']);
$idToken->validate($settings->clientId);
} catch (InvalidTokenException $exception) {
throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}");
}