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
--- /dev/null
+<?php
+
+namespace BookStack\Auth\Access\OpenIdConnect;
+
+class IssuerDiscoveryException extends \Exception
+{
+
+}
\ No newline at end of file
--- /dev/null
+<?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;
+ }
+}
\ No newline at end of file
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;
*/
public function login(): array
{
- $provider = $this->getProvider();
+ $settings = $this->getProviderSettings();
+ $provider = $this->getProvider($settings);
return [
'url' => $provider->getAuthorizationUrl(),
'state' => $provider->getState(),
* 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'],
- ];
+ ]);
+
+ // 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);
+ }
- return new OpenIdConnectOAuthProvider($settings);
+ $settings->validate();
+
+ return $settings;
+ }
+
+ /**
+ * Load the underlying OpenID Connect Provider.
+ */
+ protected function getProvider(OpenIdConnectProviderSettings $settings): OpenIdConnectOAuthProvider
+ {
+ return new OpenIdConnectOAuthProvider($settings->arrayForProvider());
}
/**
* @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']) {
}
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()}");
}
// 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),
"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",
"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",
},
"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",