]> BookStack Code Mirror - bookstack/commitdiff
Added OIDC basic autodiscovery support
authorDan Brown <redacted>
Tue, 12 Oct 2021 22:00:52 +0000 (23:00 +0100)
committerDan Brown <redacted>
Tue, 12 Oct 2021 22:00:52 +0000 (23:00 +0100)
.env.example.complete
app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php [new file with mode: 0644]
app/Auth/Access/OpenIdConnect/OpenIdConnectProviderSettings.php [new file with mode: 0644]
app/Auth/Access/OpenIdConnect/OpenIdConnectService.php
app/Config/oidc.php
composer.json
composer.lock

index e92eb5099fbbfa51ca8a3ce23b74d064e1bca662..4188751654e8c22491f8f07e2b82fe4dc10fa95f 100644 (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
diff --git a/app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php b/app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php
new file mode 100644 (file)
index 0000000..26dfca1
--- /dev/null
@@ -0,0 +1,8 @@
+<?php
+
+namespace BookStack\Auth\Access\OpenIdConnect;
+
+class IssuerDiscoveryException extends \Exception
+{
+
+}
\ No newline at end of file
diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectProviderSettings.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectProviderSettings.php
new file mode 100644 (file)
index 0000000..8ced5a9
--- /dev/null
@@ -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;
+    }
+}
\ No newline at end of file
index 7471a5007225638e59256960d31529cdb8d5e973..57c9d1238b7d565ecb46113570be03666b33af69 100644 (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'],
-        ];
+        ]);
+
+        // 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());
     }
 
     /**
@@ -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()}");
         }
index 43e8678adc7313e7f70933e43dd602400e1e2f56..1b50d9d66c9cabacb1d72e2068112f4caf2f7ede 100644 (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),
index 066e67c4faea46d95bc84262549abf913046079e..dc281e5a811ba339effd60ad7d4915a8b33af46c 100644 (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",
index d64d8d64028676110bb4cf346271234305c0da2d..89e408eb9198ba5d4a5f4c624b983bccae545f87 100644 (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",
             },
             "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",
Morty Proxy This is a proxified and sanitized view of the page, visit original site.