Added OIDC basic autodiscovery support

This commit is contained in:
Dan Brown 2021-10-12 23:00:52 +01:00
parent 790723dfc5
commit 06a0d829c8
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
7 changed files with 325 additions and 15 deletions

View File

@ -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

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()}");
}

View File

@ -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),

View File

@ -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
View File

@ -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",