mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Added OIDC basic autodiscovery support
This commit is contained in:
parent
790723dfc5
commit
06a0d829c8
@ -245,6 +245,7 @@ OIDC_DISPLAY_NAME_CLAIMS=name
|
||||
OIDC_CLIENT_ID=null
|
||||
OIDC_CLIENT_SECRET=null
|
||||
OIDC_ISSUER=null
|
||||
OIDC_ISSUER_DISCOVER=false
|
||||
OIDC_PUBLIC_KEY=null
|
||||
OIDC_AUTH_ENDPOINT=null
|
||||
OIDC_TOKEN_ENDPOINT=null
|
||||
|
@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\OpenIdConnect;
|
||||
|
||||
class IssuerDiscoveryException extends \Exception
|
||||
{
|
||||
|
||||
}
|
198
app/Auth/Access/OpenIdConnect/OpenIdConnectProviderSettings.php
Normal file
198
app/Auth/Access/OpenIdConnect/OpenIdConnectProviderSettings.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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()}");
|
||||
}
|
||||
|
@ -17,9 +17,14 @@ return [
|
||||
// OAuth2/OpenId client secret, as configured in your Authorization server.
|
||||
'client_secret' => env('OIDC_CLIENT_SECRET', null),
|
||||
|
||||
// The issuer of the identity token (id_token) this will be compared with what is returned in the token.
|
||||
// The issuer of the identity token (id_token) this will be compared with
|
||||
// what is returned in the token.
|
||||
'issuer' => env('OIDC_ISSUER', null),
|
||||
|
||||
// Auto-discover the relevant endpoints and keys from the issuer.
|
||||
// Fetched details are cached for 15 minutes.
|
||||
'discover' => env('OIDC_ISSUER_DISCOVER', false),
|
||||
|
||||
// Public key that's used to verify the JWT token with.
|
||||
// Can be the key value itself or a local 'file://public.key' reference.
|
||||
'jwt_public_key' => env('OIDC_PUBLIC_KEY', null),
|
||||
|
@ -25,6 +25,7 @@
|
||||
"league/commonmark": "^1.5",
|
||||
"league/flysystem-aws-s3-v3": "^1.0.29",
|
||||
"league/html-to-markdown": "^5.0.0",
|
||||
"league/oauth2-client": "^2.6",
|
||||
"nunomaduro/collision": "^3.1",
|
||||
"onelogin/php-saml": "^4.0",
|
||||
"phpseclib/phpseclib": "~3.0",
|
||||
|
72
composer.lock
generated
72
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "ef6a8bb7bc6e99c70eeabc7695fc56eb",
|
||||
"content-hash": "b82cfdfe8bb32847ba2188804858d5fd",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
@ -2536,6 +2536,76 @@
|
||||
},
|
||||
"time": "2021-08-15T23:05:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/oauth2-client",
|
||||
"version": "2.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/oauth2-client.git",
|
||||
"reference": "badb01e62383430706433191b82506b6df24ad98"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/badb01e62383430706433191b82506b6df24ad98",
|
||||
"reference": "badb01e62383430706433191b82506b6df24ad98",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"guzzlehttp/guzzle": "^6.0 || ^7.0",
|
||||
"paragonie/random_compat": "^1 || ^2 || ^9.99",
|
||||
"php": "^5.6 || ^7.0 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.3",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.2",
|
||||
"phpunit/phpunit": "^5.7 || ^6.0 || ^9.3",
|
||||
"squizlabs/php_codesniffer": "^2.3 || ^3.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-2.x": "2.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\OAuth2\\Client\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alex Bilbie",
|
||||
"email": "hello@alexbilbie.com",
|
||||
"homepage": "http://www.alexbilbie.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Woody Gilk",
|
||||
"homepage": "https://github.com/shadowhand",
|
||||
"role": "Contributor"
|
||||
}
|
||||
],
|
||||
"description": "OAuth 2.0 Client Library",
|
||||
"keywords": [
|
||||
"Authentication",
|
||||
"SSO",
|
||||
"authorization",
|
||||
"identity",
|
||||
"idp",
|
||||
"oauth",
|
||||
"oauth2",
|
||||
"single sign on"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/oauth2-client/issues",
|
||||
"source": "https://github.com/thephpleague/oauth2-client/tree/2.6.0"
|
||||
},
|
||||
"time": "2020-10-28T02:03:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "2.3.5",
|
||||
|
Loading…
Reference in New Issue
Block a user