diff --git a/app/Auth/Access/Oidc/OidcIdToken.php b/app/Auth/Access/Oidc/OidcIdToken.php index aa68e29a9..de9c42ab2 100644 --- a/app/Auth/Access/Oidc/OidcIdToken.php +++ b/app/Auth/Access/Oidc/OidcIdToken.php @@ -134,7 +134,7 @@ class OidcIdToken try { return new OidcJwtSigningKey($key); } catch (OidcInvalidKeyException $e) { - return null; + throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage()); } }, $this->keys); diff --git a/app/Auth/Access/Oidc/OidcService.php b/app/Auth/Access/Oidc/OidcService.php index be6a5c3c4..d59d274e8 100644 --- a/app/Auth/Access/Oidc/OidcService.php +++ b/app/Auth/Access/Oidc/OidcService.php @@ -8,9 +8,10 @@ use BookStack\Exceptions\OpenIdConnectException; use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\UserRegistrationException; use Exception; -use GuzzleHttp\Client; use Illuminate\Support\Facades\Cache; +use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider; use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\ClientInterface as HttpClient; use function auth; use function config; use function trans; @@ -24,16 +25,16 @@ class OidcService { protected $registrationService; protected $loginService; - protected $config; + protected $httpClient; /** * OpenIdService constructor. */ - public function __construct(RegistrationService $registrationService, LoginService $loginService) + public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient) { - $this->config = config('oidc'); $this->registrationService = $registrationService; $this->loginService = $loginService; + $this->httpClient = $httpClient; } /** @@ -77,23 +78,24 @@ class OidcService */ protected function getProviderSettings(): OidcProviderSettings { + $config = $this->config(); $settings = new OidcProviderSettings([ - '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'], + 'issuer' => $config['issuer'], + 'clientId' => $config['client_id'], + 'clientSecret' => $config['client_secret'], + 'redirectUri' => url('/oidc/callback'), + 'authorizationEndpoint' => $config['authorization_endpoint'], + 'tokenEndpoint' => $config['token_endpoint'], ]); // Use keys if configured - if (!empty($this->config['jwt_public_key'])) { - $settings->keys = [$this->config['jwt_public_key']]; + if (!empty($config['jwt_public_key'])) { + $settings->keys = [$config['jwt_public_key']]; } // Run discovery - if ($this->config['discover'] ?? false) { - $settings->discoverFromIssuer(new Client(['timeout' => 3]), Cache::store(null), 15); + if ($config['discover'] ?? false) { + $settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15); } $settings->validate(); @@ -106,7 +108,10 @@ class OidcService */ protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider { - return new OidcOAuthProvider($settings->arrayForProvider()); + return new OidcOAuthProvider($settings->arrayForProvider(), [ + 'httpClient' => $this->httpClient, + 'optionProvider' => new HttpBasicAuthOptionProvider(), + ]); } /** @@ -114,7 +119,7 @@ class OidcService */ protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string { - $displayNameAttr = $this->config['display_name_claims']; + $displayNameAttr = $this->config()['display_name_claims']; $displayName = []; foreach ($displayNameAttr as $dnAttr) { @@ -162,7 +167,7 @@ class OidcService $settings->keys, ); - if ($this->config['dump_user_details']) { + if ($this->config()['dump_user_details']) { throw new JsonDebugException($idToken->getAllClaims()); } @@ -175,7 +180,7 @@ class OidcService $userDetails = $this->getUserDetails($idToken); $isLoggedIn = auth()->check(); - if ($userDetails['email'] === null) { + if (empty($userDetails['email'])) { throw new OpenIdConnectException(trans('errors.oidc_no_email_address')); } @@ -194,4 +199,12 @@ class OidcService $this->loginService->login($user, 'oidc'); return $user; } + + /** + * Get the OIDC config from the application. + */ + protected function config(): array + { + return config('oidc'); + } } diff --git a/app/Http/Controllers/Auth/OpenIdConnectController.php b/app/Http/Controllers/Auth/OidcController.php similarity index 73% rename from app/Http/Controllers/Auth/OpenIdConnectController.php rename to app/Http/Controllers/Auth/OidcController.php index 03638847b..f4103cb0a 100644 --- a/app/Http/Controllers/Auth/OpenIdConnectController.php +++ b/app/Http/Controllers/Auth/OidcController.php @@ -6,7 +6,7 @@ use BookStack\Auth\Access\Oidc\OidcService; use BookStack\Http\Controllers\Controller; use Illuminate\Http\Request; -class OpenIdConnectController extends Controller +class OidcController extends Controller { protected $oidcService; @@ -32,10 +32,10 @@ class OpenIdConnectController extends Controller } /** - * Authorization flow redirect. + * Authorization flow redirect callback. * Processes authorization response from the OIDC Authorization Server. */ - public function redirect(Request $request) + public function callback(Request $request) { $storedState = session()->pull('oidc_state'); $responseState = $request->query('state'); @@ -45,12 +45,7 @@ class OpenIdConnectController extends Controller return redirect('/login'); } - $user = $this->oidcService->processAuthorizeResponse($request->query('code')); - if ($user === null) { - $this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')])); - return redirect('/login'); - } - + $this->oidcService->processAuthorizeResponse($request->query('code')); return redirect()->intended(); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8334bb179..18e1fb627 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -12,6 +12,7 @@ use BookStack\Entities\Models\Page; use BookStack\Settings\Setting; use BookStack\Settings\SettingService; use BookStack\Util\CspService; +use GuzzleHttp\Client; use Illuminate\Contracts\Cache\Repository; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Blade; @@ -20,6 +21,7 @@ use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use Laravel\Socialite\Contracts\Factory as SocialiteFactory; +use Psr\Http\Client\ClientInterface as HttpClientInterface; class AppServiceProvider extends ServiceProvider { @@ -76,5 +78,9 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(CspService::class, function ($app) { return new CspService(); }); + + $this->app->bind(HttpClientInterface::class, function($app) { + return new Client(['timeout' => 3]); + }); } } diff --git a/routes/web.php b/routes/web.php index 72e0392cc..254076451 100644 --- a/routes/web.php +++ b/routes/web.php @@ -268,8 +268,8 @@ Route::get('/saml2/sls', 'Auth\Saml2Controller@sls'); Route::post('/saml2/acs', 'Auth\Saml2Controller@acs'); // OIDC routes -Route::post('/oidc/login', 'Auth\OpenIdConnectController@login'); -Route::get('/oidc/redirect', 'Auth\OpenIdConnectController@redirect'); +Route::post('/oidc/login', 'Auth\OidcController@login'); +Route::get('/oidc/callback', 'Auth\OidcController@callback'); // User invitation routes Route::get('/register/invite/{token}', 'Auth\UserInviteController@showSetPassword'); diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index 1ffcc0815..d83f25557 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -318,7 +318,7 @@ class AuthTest extends TestCase $this->assertTrue(auth()->check()); $this->assertTrue(auth('ldap')->check()); $this->assertTrue(auth('saml2')->check()); - $this->assertTrue(auth('openid')->check()); + $this->assertTrue(auth('oidc')->check()); } public function test_login_authenticates_nonadmins_on_default_guard_only() @@ -331,7 +331,7 @@ class AuthTest extends TestCase $this->assertTrue(auth()->check()); $this->assertFalse(auth('ldap')->check()); $this->assertFalse(auth('saml2')->check()); - $this->assertFalse(auth('openid')->check()); + $this->assertFalse(auth('oidc')->check()); } public function test_failed_logins_are_logged_when_message_configured() diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php new file mode 100644 index 000000000..cf04080fc --- /dev/null +++ b/tests/Auth/OidcTest.php @@ -0,0 +1,381 @@ +keyFile = tmpfile(); + $this->keyFilePath = 'file://' . stream_get_meta_data($this->keyFile)['uri']; + file_put_contents($this->keyFilePath, OidcJwtHelper::publicPemKey()); + + config()->set([ + 'auth.method' => 'oidc', + 'auth.defaults.guard' => 'oidc', + 'oidc.name' => 'SingleSignOn-Testing', + 'oidc.display_name_claims' => ['name'], + 'oidc.client_id' => OidcJwtHelper::defaultClientId(), + 'oidc.client_secret' => 'testpass', + 'oidc.jwt_public_key' => $this->keyFilePath, + 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), + 'oidc.authorization_endpoint' => 'https://oidc.local/auth', + 'oidc.token_endpoint' => 'https://oidc.local/token', + 'oidc.discover' => false, + 'oidc.dump_user_details' => false, + ]); + } + + public function tearDown(): void + { + parent::tearDown(); + if (file_exists($this->keyFilePath)) { + unlink($this->keyFilePath); + } + } + + public function test_login_option_shows_on_login_page() + { + $req = $this->get('/login'); + $req->assertSeeText('SingleSignOn-Testing'); + $req->assertElementExists('form[action$="/oidc/login"][method=POST] button'); + } + + public function test_oidc_routes_are_only_active_if_oidc_enabled() + { + config()->set(['auth.method' => 'standard']); + $routes = ['/login' => 'post', '/callback' => 'get']; + foreach ($routes as $uri => $method) { + $req = $this->call($method, '/oidc' . $uri); + $this->assertPermissionError($req); + } + } + + public function test_forgot_password_routes_inaccessible() + { + $resp = $this->get('/password/email'); + $this->assertPermissionError($resp); + + $resp = $this->post('/password/email'); + $this->assertPermissionError($resp); + + $resp = $this->get('/password/reset/abc123'); + $this->assertPermissionError($resp); + + $resp = $this->post('/password/reset'); + $this->assertPermissionError($resp); + } + + public function test_standard_login_routes_inaccessible() + { + $resp = $this->post('/login'); + $this->assertPermissionError($resp); + } + + public function test_logout_route_functions() + { + $this->actingAs($this->getEditor()); + $this->get('/logout'); + $this->assertFalse(auth()->check()); + } + + public function test_user_invite_routes_inaccessible() + { + $resp = $this->get('/register/invite/abc123'); + $this->assertPermissionError($resp); + + $resp = $this->post('/register/invite/abc123'); + $this->assertPermissionError($resp); + } + + public function test_user_register_routes_inaccessible() + { + $resp = $this->get('/register'); + $this->assertPermissionError($resp); + + $resp = $this->post('/register'); + $this->assertPermissionError($resp); + } + + public function test_login() + { + $req = $this->post('/oidc/login'); + $redirect = $req->headers->get('location'); + + $this->assertStringStartsWith('https://oidc.local/auth', $redirect, 'Login redirects to SSO location'); + $this->assertFalse($this->isAuthenticated()); + $this->assertStringContainsString('scope=openid%20profile%20email', $redirect); + $this->assertStringContainsString('client_id=' . OidcJwtHelper::defaultClientId(), $redirect); + $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $redirect); + } + + public function test_login_success_flow() + { + // Start auth + $this->post('/oidc/login'); + $state = session()->get('oidc_state'); + + $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([ + 'email' => 'benny@example.com', + 'sub' => 'benny1010101' + ])]); + + // Callback from auth provider + // App calls token endpoint to get id token + $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); + $resp->assertRedirect('/'); + $this->assertCount(1, $transactions); + /** @var Request $tokenRequest */ + $tokenRequest = $transactions[0]['request']; + $this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri()); + $this->assertEquals('POST', $tokenRequest->getMethod()); + $this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]); + $this->assertStringContainsString('grant_type=authorization_code', $tokenRequest->getBody()); + $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody()); + $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody()); + + + $this->assertTrue(auth()->check()); + $this->assertDatabaseHas('users', [ + 'email' => 'benny@example.com', + 'external_auth_id' => 'benny1010101', + 'email_confirmed' => false, + ]); + + $user = User::query()->where('email', '=', 'benny@example.com')->first(); + $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott"); + } + + public function test_callback_fails_if_no_state_present_or_matching() + { + $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124'); + $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + + $this->post('/oidc/login'); + $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124'); + $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + } + + public function test_dump_user_details_option_outputs_as_expected() + { + config()->set('oidc.dump_user_details', true); + + $resp = $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny505' + ]); + + $resp->assertStatus(200); + $resp->assertJson([ + 'email' => 'benny@example.com', + 'sub' => 'benny505', + "iss" => OidcJwtHelper::defaultIssuer(), + "aud" => OidcJwtHelper::defaultClientId(), + ]); + $this->assertFalse(auth()->check()); + } + + public function test_auth_fails_if_no_email_exists_in_user_data() + { + $this->runLogin([ + 'email' => '', + 'sub' => 'benny505' + ]); + + $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system'); + } + + public function test_auth_fails_if_already_logged_in() + { + $this->asEditor(); + + $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny505' + ]); + + $this->assertSessionError('Already logged in'); + } + + public function test_auth_login_as_existing_user() + { + $editor = $this->getEditor(); + $editor->external_auth_id = 'benny505'; + $editor->save(); + + $this->assertFalse(auth()->check()); + + $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny505' + ]); + + $this->assertTrue(auth()->check()); + $this->assertEquals($editor->id, auth()->user()->id); + } + + public function test_auth_login_as_existing_user_email_with_different_auth_id_fails() + { + $editor = $this->getEditor(); + $editor->external_auth_id = 'editor101'; + $editor->save(); + + $this->assertFalse(auth()->check()); + + $this->runLogin([ + 'email' => $editor->email, + 'sub' => 'benny505' + ]); + + $this->assertSessionError('A user with the email ' . $editor->email . ' already exists but with different credentials.'); + $this->assertFalse(auth()->check()); + } + + public function test_auth_login_with_invalid_token_fails() + { + $this->runLogin([ + 'sub' => null, + ]); + + $this->assertSessionError('ID token validate failed with error: Missing token subject value'); + $this->assertFalse(auth()->check()); + } + + public function test_auth_login_with_autodiscovery() + { + $this->withAutodiscovery(); + + $transactions = &$this->mockHttpClient([ + $this->getAutoDiscoveryResponse(), + $this->getJwksResponse(), + ]); + + $this->assertFalse(auth()->check()); + + $this->runLogin(); + + $this->assertTrue(auth()->check()); + /** @var Request $discoverRequest */ + $discoverRequest = $transactions[0]['request']; + /** @var Request $discoverRequest */ + $keysRequest = $transactions[1]['request']; + + $this->assertEquals('GET', $keysRequest->getMethod()); + $this->assertEquals('GET', $discoverRequest->getMethod()); + $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri()); + $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/oidc/keys', $keysRequest->getUri()); + } + + public function test_auth_fails_if_autodiscovery_fails() + { + $this->withAutodiscovery(); + $this->mockHttpClient([ + new Response(404, [], 'Not found'), + ]); + + $this->runLogin(); + $this->assertFalse(auth()->check()); + $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + } + + public function test_autodiscovery_calls_are_cached() + { + $this->withAutodiscovery(); + + $transactions = &$this->mockHttpClient([ + $this->getAutoDiscoveryResponse(), + $this->getJwksResponse(), + $this->getAutoDiscoveryResponse([ + 'issuer' => 'https://auto.example.com' + ]), + $this->getJwksResponse(), + ]); + + // Initial run + $this->post('/oidc/login'); + $this->assertCount(2, $transactions); + // Second run, hits cache + $this->post('/oidc/login'); + $this->assertCount(2, $transactions); + + // Third run, different issuer, new cache key + config()->set(['oidc.issuer' => 'https://auto.example.com']); + $this->post('/oidc/login'); + $this->assertCount(4, $transactions); + } + + protected function withAutodiscovery() + { + config()->set([ + 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), + 'oidc.discover' => true, + 'oidc.authorization_endpoint' => null, + 'oidc.token_endpoint' => null, + 'oidc.jwt_public_key' => null, + ]); + } + + protected function runLogin($claimOverrides = []): TestResponse + { + $this->post('/oidc/login'); + $state = session()->get('oidc_state'); + $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]); + + return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); + } + + protected function getAutoDiscoveryResponse($responseOverrides = []): Response + { + return new Response(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache, no-store', + 'Pragma' => 'no-cache' + ], json_encode(array_merge([ + 'token_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/token', + 'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize', + 'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys', + 'issuer' => OidcJwtHelper::defaultIssuer() + ], $responseOverrides))); + } + + protected function getJwksResponse(): Response + { + return new Response(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache, no-store', + 'Pragma' => 'no-cache' + ], json_encode([ + 'keys' => [ + OidcJwtHelper::publicJwkKeyArray() + ] + ])); + } + + protected function getMockAuthorizationResponse($claimOverrides = []): Response + { + return new Response(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache, no-store', + 'Pragma' => 'no-cache' + ], json_encode([ + 'access_token' => 'abc123', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'id_token' => OidcJwtHelper::idToken($claimOverrides) + ])); + } +} diff --git a/tests/Auth/OpenIdTest.php b/tests/Auth/OpenIdTest.php deleted file mode 100644 index 9ad90fb5b..000000000 --- a/tests/Auth/OpenIdTest.php +++ /dev/null @@ -1,112 +0,0 @@ -set([ - 'auth.method' => 'openid', - 'auth.defaults.guard' => 'openid', - 'openid.name' => 'SingleSignOn-Testing', - 'openid.email_attribute' => 'email', - 'openid.display_name_attributes' => ['given_name', 'family_name'], - 'openid.external_id_attribute' => 'uid', - 'openid.openid_overrides' => null, - 'openid.openid.clientId' => 'testapp', - 'openid.openid.clientSecret' => 'testpass', - 'openid.openid.publicKey' => $this->testCert, - 'openid.openid.idTokenIssuer' => 'https://openid.local', - 'openid.openid.urlAuthorize' => 'https://openid.local/auth', - 'openid.openid.urlAccessToken' => 'https://openid.local/token', - ]); - } - - public function test_openid_overrides_functions_as_expected() - { - $json = '{"urlAuthorize": "https://openid.local/custom"}'; - config()->set(['openid.openid_overrides' => $json]); - - $req = $this->get('/openid/login'); - $redirect = $req->headers->get('location'); - $this->assertStringStartsWith('https://openid.local/custom', $redirect, 'Login redirects to SSO location'); - } - - public function test_login_option_shows_on_login_page() - { - $req = $this->get('/login'); - $req->assertSeeText('SingleSignOn-Testing'); - $req->assertElementExists('form[action$="/openid/login"][method=POST] button'); - } - - public function test_login() - { - $req = $this->post('/openid/login'); - $redirect = $req->headers->get('location'); - - $this->assertStringStartsWith('https://openid.local/auth', $redirect, 'Login redirects to SSO location'); - $this->assertFalse($this->isAuthenticated()); - } - - public function test_openid_routes_are_only_active_if_openid_enabled() - { - config()->set(['auth.method' => 'standard']); - $getRoutes = ['/logout', '/metadata', '/sls']; - foreach ($getRoutes as $route) { - $req = $this->get('/openid' . $route); - $this->assertPermissionError($req); - } - - $postRoutes = ['/login', '/acs']; - foreach ($postRoutes as $route) { - $req = $this->post('/openid' . $route); - $this->assertPermissionError($req); - } - } - - public function test_forgot_password_routes_inaccessible() - { - $resp = $this->get('/password/email'); - $this->assertPermissionError($resp); - - $resp = $this->post('/password/email'); - $this->assertPermissionError($resp); - - $resp = $this->get('/password/reset/abc123'); - $this->assertPermissionError($resp); - - $resp = $this->post('/password/reset'); - $this->assertPermissionError($resp); - } - - public function test_standard_login_routes_inaccessible() - { - $resp = $this->post('/login'); - $this->assertPermissionError($resp); - - $resp = $this->get('/logout'); - $this->assertPermissionError($resp); - } - - public function test_user_invite_routes_inaccessible() - { - $resp = $this->get('/register/invite/abc123'); - $this->assertPermissionError($resp); - - $resp = $this->post('/register/invite/abc123'); - $this->assertPermissionError($resp); - } - - public function test_user_register_routes_inaccessible() - { - $resp = $this->get('/register'); - $this->assertPermissionError($resp); - - $resp = $this->post('/register'); - $this->assertPermissionError($resp); - } -} diff --git a/tests/Helpers/OidcJwtHelper.php b/tests/Helpers/OidcJwtHelper.php new file mode 100644 index 000000000..0c44efb01 --- /dev/null +++ b/tests/Helpers/OidcJwtHelper.php @@ -0,0 +1,132 @@ + "abc1234def", + "name" => "Barry Scott", + "email" => "bscott@example.com", + "ver" => 1, + "iss" => static::defaultIssuer(), + "aud" => static::defaultClientId(), + "iat" => time(), + "exp" => time() + 720, + "jti" => "ID.AaaBBBbbCCCcccDDddddddEEEeeeeee", + "amr" => ["pwd"], + "idp" => "fghfghgfh546456dfgdfg", + "preferred_username" => "xXBazzaXx", + "auth_time" => time(), + "at_hash" => "sT4jbsdSGy9w12pq3iNYDA", + ]; + } + + public static function idToken($payloadOverrides = [], $headerOverrides = []): string + { + $payload = array_merge(static::defaultPayload(), $payloadOverrides); + $header = array_merge([ + 'kid' => 'xyz456', + 'alg' => 'RS256', + ], $headerOverrides); + + $top = implode('.', [ + static::base64UrlEncode(json_encode($header)), + static::base64UrlEncode(json_encode($payload)), + ]); + + $privateKey = static::privateKeyInstance(); + $signature = $privateKey->sign($top); + return $top . '.' . static::base64UrlEncode($signature); + } + + public static function privateKeyInstance() + { + static $key; + if (is_null($key)) { + $key = RSA::loadPrivateKey(static::privatePemKey())->withPadding(RSA::SIGNATURE_PKCS1); + } + + return $key; + } + + public static function base64UrlEncode(string $decoded): string + { + return strtr(base64_encode($decoded), '+/', '-_'); + } + + public static function publicPemKey(): string + { + return "-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqo1OmfNKec5S2zQC4SP9 +DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvm +zXL16c93Obn7G8x8A3ao6yN5qKO5S5+CETqOZfKN/g75Xlz7VsC3igOhgsXnPx6i +iM6sbYbk0U/XpFaT84LXKI8VTIPUo7gTeZN1pTET//i9FlzAOzX+xfWBKdOqlEzl ++zihMHCZUUvQu99P+o0MDR0lMUT+vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNk +WvsLta1+jNUee+8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw +3wIDAQAB +-----END PUBLIC KEY-----"; + } + + public static function privatePemKey(): string + { + return "-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqjU6Z80p5zlLb +NALhI/0Ose5RHRWAKLqipwYRHPvOo7fqGqTcDdHdoKAmQSN+ducy6zNFEqzjk1te +g6n2m+bNcvXpz3c5ufsbzHwDdqjrI3moo7lLn4IROo5l8o3+DvleXPtWwLeKA6GC +xec/HqKIzqxthuTRT9ekVpPzgtcojxVMg9SjuBN5k3WlMRP/+L0WXMA7Nf7F9YEp +06qUTOX7OKEwcJlRS9C730/6jQwNHSUxRP688npIl5F+egd7HC3ptkWI2exkgQvT +dtfhA2Ra+wu1rX6M1R577wg9WHMI7xu8zzo3MtopQniTo1nkhWuZ0IWkWyMJYHI6 +sMbzB3DfAgMBAAECggEADm7K2ghWoxwsstQh8j+DaLzx9/dIHIJV2PHdd5FGVeRQ +6gS7MswQmHrBUrtsb4VMZ2iz/AJqkw+jScpGldH3pCc4XELsSfxNHbseO4TNIqjr +4LOKOLYU4bRc3I+8KGXIAI5JzrucTJemEVUCDrte8cjbmqExt+zTyNpyxsapworF +v+vnSdv40d62f+cS1xvwB+ymLK/B/wZ/DemDCi8jsi7ou/M7l5xNCzjH4iMSLtOW +fgEhejIBG9miMJWPiVpTXE3tMdNuN3OsWc4XXm2t4VRovlZdu30Fax1xWB+Locsv +HlHKLOFc8g+jZh0TL2KCNjPffMcC7kHhW3afshpIsQKBgQDhyWUnkqd6FzbwIX70 +SnaMgKoUv5W/K5T+Sv/PA2CyN8Gu8ih/OsoNZSnI0uqe3XQIvvgN/Fq3wO1ttLzf +z5B6ZC7REfTgcR0190gihk6f5rtcj7d6Fy/oG2CE8sDSXgPnpEaBjvJVgN5v/U2s +HpVaidmHTyGLCfEszoeoy8jyrQKBgQDBX8caGylmzQLc6XNntZChlt3e18Nj8MPA +DxWLcoqgdDoofLDQAmLl+vPKyDmhQjos5eas1jgmVVEM4ge+MysaVezvuLBsSnOh +ihc0i63USU6i7YDE83DrCewCthpFHi/wW1S5FoCAzpVy8y99vwcqO4kOXcmf4O6Y +uW6sMsjvOwKBgQDbFtqB+MtsLCSSBF61W6AHHD5tna4H75lG2623yXZF2NanFLF5 +K6muL9DI3ujtOMQETJJUt9+rWJjLEEsJ/dYa/SV0l7D/LKOEnyuu3JZkkLaTzZzi +6qcA2bfhqdCzEKlHV99WjkfV8hNlpex9rLuOPB8JLh7FVONicBGxF/UojQKBgDXs +IlYaSuI6utilVKQP0kPtEPOKERc2VS+iRSy8hQGXR3xwwNFQSQm+f+sFCGT6VcSd +W0TI+6Fc2xwPj38vP465dTentbKM1E+wdSYW6SMwSfhO6ECDbfJsst5Sr2Kkt1N7 +9FUkfDLu6GfEfnK/KR1SurZB2u51R7NYyg7EnplvAoGAT0aTtOcck0oYN30g5mdf +efqXPwg2wAPYeiec49EbfnteQQKAkqNfJ9K69yE2naf6bw3/5mCBsq/cXeuaBMII +ylysUIRBqt2J0kWm2yCpFWR7H+Ilhdx9A7ZLCqYVt8e+vjO/BOI3cQDe2VPOLPSl +q/1PY4iJviGKddtmfClH3v4= +-----END PRIVATE KEY-----"; + } + + public static function publicJwkKeyArray(): array + { + return [ + 'kty' => 'RSA', + 'alg' => 'RS256', + 'kid' => '066e52af-8884-4926-801d-032a276f9f2a', + 'use' => 'sig', + 'e' => 'AQAB', + 'n' => 'qo1OmfNKec5S2zQC4SP9DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvmzXL16c93Obn7G8x8A3ao6yN5qKO5S5-CETqOZfKN_g75Xlz7VsC3igOhgsXnPx6iiM6sbYbk0U_XpFaT84LXKI8VTIPUo7gTeZN1pTET__i9FlzAOzX-xfWBKdOqlEzl-zihMHCZUUvQu99P-o0MDR0lMUT-vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNkWvsLta1-jNUee-8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw3w', + ]; + } +} \ No newline at end of file diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php index e4d27c849..606a3cd9e 100644 --- a/tests/SharedTestHelpers.php +++ b/tests/SharedTestHelpers.php @@ -18,6 +18,10 @@ use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Repos\PageRepo; use BookStack\Settings\SettingService; use BookStack\Uploads\HttpFetcher; +use GuzzleHttp\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; use Illuminate\Foundation\Testing\Assert as PHPUnit; use Illuminate\Http\JsonResponse; use Illuminate\Support\Env; @@ -25,6 +29,7 @@ use Illuminate\Support\Facades\Log; use Mockery; use Monolog\Handler\TestHandler; use Monolog\Logger; +use Psr\Http\Client\ClientInterface; trait SharedTestHelpers { @@ -244,6 +249,22 @@ trait SharedTestHelpers ->andReturn($returnData); } + /** + * Mock the http client used in BookStack. + * Returns a reference to the container which holds all history of http transactions. + * @link https://docs.guzzlephp.org/en/stable/testing.html#history-middleware + */ + protected function &mockHttpClient(array $responses = []): array + { + $container = []; + $history = Middleware::history($container); + $mock = new MockHandler($responses); + $handlerStack = new HandlerStack($mock); + $handlerStack->push($history); + $this->app[ClientInterface::class] = new Client(['handler' => $handlerStack]); + return $container; + } + /** * Run a set test with the given env variable. * Remembers the original and resets the value after test. @@ -323,6 +344,15 @@ trait SharedTestHelpers ); } + /** + * Assert that the session has a particular error notification message set. + */ + protected function assertSessionError(string $message) + { + $error = session()->get('error'); + PHPUnit::assertTrue($error === $message, "Failed asserting the session contains an error. \nFound: {$error}\nExpecting: {$message}"); + } + /** * Set a test handler as the logging interface for the application. * Allows capture of logs for checking against during tests. diff --git a/tests/Unit/OidcIdTokenTest.php b/tests/Unit/OidcIdTokenTest.php index abc811f75..b08d578b3 100644 --- a/tests/Unit/OidcIdTokenTest.php +++ b/tests/Unit/OidcIdTokenTest.php @@ -4,15 +4,15 @@ namespace Tests\Unit; use BookStack\Auth\Access\Oidc\OidcInvalidTokenException; use BookStack\Auth\Access\Oidc\OidcIdToken; -use phpseclib3\Crypt\RSA; +use Tests\Helpers\OidcJwtHelper; use Tests\TestCase; class OidcIdTokenTest extends TestCase { public function test_valid_token_passes_validation() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', [ - $this->jwkKeyArray() + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [ + OidcJwtHelper::publicJwkKeyArray() ]); $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); @@ -20,26 +20,26 @@ class OidcIdTokenTest extends TestCase public function test_get_claim_returns_value_if_existing() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', []); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []); $this->assertEquals('bscott@example.com', $token->getClaim('email')); } public function test_get_claim_returns_null_if_not_existing() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', []); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []); $this->assertEquals(null, $token->getClaim('emails')); } public function test_get_all_claims_returns_all_payload_claims() { - $defaultPayload = $this->getDefaultPayload(); - $token = new OidcIdToken($this->idToken($defaultPayload), 'https://auth.example.com', []); + $defaultPayload = OidcJwtHelper::defaultPayload(); + $token = new OidcIdToken(OidcJwtHelper::idToken($defaultPayload), OidcJwtHelper::defaultIssuer(), []); $this->assertEquals($defaultPayload, $token->getAllClaims()); } public function test_token_structure_error_cases() { - $idToken = $this->idToken(); + $idToken = OidcJwtHelper::idToken(); $idTokenExploded = explode('.', $idToken); $messagesAndTokenValues = [ @@ -52,7 +52,7 @@ class OidcIdTokenTest extends TestCase ]; foreach ($messagesAndTokenValues as [$message, $tokenValue]) { - $token = new OidcIdToken($tokenValue, 'https://auth.example.com', []); + $token = new OidcIdToken($tokenValue, OidcJwtHelper::defaultIssuer(), []); $err = null; try { $token->validate('abc'); @@ -67,7 +67,7 @@ class OidcIdTokenTest extends TestCase public function test_error_thrown_if_token_signature_not_validated_from_no_keys() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', []); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []); $this->expectException(OidcInvalidTokenException::class); $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); $token->validate('abc'); @@ -75,8 +75,8 @@ class OidcIdTokenTest extends TestCase public function test_error_thrown_if_token_signature_not_validated_from_non_matching_key() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', [ - array_merge($this->jwkKeyArray(), [ + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [ + array_merge(OidcJwtHelper::publicJwkKeyArray(), [ 'n' => 'iqK-1QkICMf_cusNLpeNnN-bhT0-9WLBvzgwKLALRbrevhdi5ttrLHIQshaSL0DklzfyG2HWRmAnJ9Q7sweEjuRiiqRcSUZbYu8cIv2hLWYu7K_NH67D2WUjl0EnoHEuiVLsZhQe1CmdyLdx087j5nWkd64K49kXRSdxFQUlj8W3NeK3CjMEUdRQ3H4RZzJ4b7uuMiFA29S2ZhMNG20NPbkUVsFL-jiwTd10KSsPT8yBYipI9O7mWsUWt_8KZs1y_vpM_k3SyYihnWpssdzDm1uOZ8U3mzFr1xsLAO718GNUSXk6npSDzLl59HEqa6zs4O9awO2qnSHvcmyELNk31w' ]) ]); @@ -85,17 +85,17 @@ class OidcIdTokenTest extends TestCase $token->validate('abc'); } - public function test_error_thrown_if_token_signature_not_validated_from_invalid_key() + public function test_error_thrown_if_invalid_key_provided() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', ['url://example.com']); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), ['url://example.com']); $this->expectException(OidcInvalidTokenException::class); - $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); + $this->expectExceptionMessage('Unexpected type of key value provided'); $token->validate('abc'); } public function test_error_thrown_if_token_algorithm_is_not_rs256() { - $token = new OidcIdToken($this->idToken([], ['alg' => 'HS256']), 'https://auth.example.com', []); + $token = new OidcIdToken(OidcJwtHelper::idToken([], ['alg' => 'HS256']), OidcJwtHelper::defaultIssuer(), []); $this->expectException(OidcInvalidTokenException::class); $this->expectExceptionMessage("Only RS256 signature validation is supported. Token reports using HS256"); $token->validate('abc'); @@ -133,8 +133,8 @@ class OidcIdTokenTest extends TestCase ]; foreach ($claimOverridesByErrorMessage as [$message, $overrides]) { - $token = new OidcIdToken($this->idToken($overrides), 'https://auth.example.com', [ - $this->jwkKeyArray() + $token = new OidcIdToken(OidcJwtHelper::idToken($overrides), OidcJwtHelper::defaultIssuer(), [ + OidcJwtHelper::publicJwkKeyArray() ]); $err = null; @@ -153,122 +153,12 @@ class OidcIdTokenTest extends TestCase { $file = tmpfile(); $testFilePath = 'file://' . stream_get_meta_data($file)['uri']; - file_put_contents($testFilePath, $this->pemKey()); - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', [ + file_put_contents($testFilePath, OidcJwtHelper::publicPemKey()); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [ $testFilePath ]); $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); unlink($testFilePath); } - - protected function getDefaultPayload(): array - { - return [ - "sub" => "abc1234def", - "name" => "Barry Scott", - "email" => "bscott@example.com", - "ver" => 1, - "iss" => "https://auth.example.com", - "aud" => "xxyyzz.aaa.bbccdd.123", - "iat" => time(), - "exp" => time() + 720, - "jti" => "ID.AaaBBBbbCCCcccDDddddddEEEeeeeee", - "amr" => ["pwd"], - "idp" => "fghfghgfh546456dfgdfg", - "preferred_username" => "xXBazzaXx", - "auth_time" => time(), - "at_hash" => "sT4jbsdSGy9w12pq3iNYDA", - ]; - } - - protected function idToken($payloadOverrides = [], $headerOverrides = []): string - { - $payload = array_merge($this->getDefaultPayload(), $payloadOverrides); - $header = array_merge([ - 'kid' => 'xyz456', - 'alg' => 'RS256', - ], $headerOverrides); - - $top = implode('.', [ - $this->base64UrlEncode(json_encode($header)), - $this->base64UrlEncode(json_encode($payload)), - ]); - - $privateKey = $this->getPrivateKey(); - $signature = $privateKey->sign($top); - return $top . '.' . $this->base64UrlEncode($signature); - } - - protected function getPrivateKey() - { - static $key; - if (is_null($key)) { - $key = RSA::loadPrivateKey($this->privatePemKey())->withPadding(RSA::SIGNATURE_PKCS1); - } - - return $key; - } - - protected function base64UrlEncode(string $decoded): string - { - return strtr(base64_encode($decoded), '+/', '-_'); - } - - protected function pemKey(): string - { - return "-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqo1OmfNKec5S2zQC4SP9 -DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvm -zXL16c93Obn7G8x8A3ao6yN5qKO5S5+CETqOZfKN/g75Xlz7VsC3igOhgsXnPx6i -iM6sbYbk0U/XpFaT84LXKI8VTIPUo7gTeZN1pTET//i9FlzAOzX+xfWBKdOqlEzl -+zihMHCZUUvQu99P+o0MDR0lMUT+vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNk -WvsLta1+jNUee+8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw -3wIDAQAB ------END PUBLIC KEY-----"; - } - - protected function privatePemKey(): string - { - return "-----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqjU6Z80p5zlLb -NALhI/0Ose5RHRWAKLqipwYRHPvOo7fqGqTcDdHdoKAmQSN+ducy6zNFEqzjk1te -g6n2m+bNcvXpz3c5ufsbzHwDdqjrI3moo7lLn4IROo5l8o3+DvleXPtWwLeKA6GC -xec/HqKIzqxthuTRT9ekVpPzgtcojxVMg9SjuBN5k3WlMRP/+L0WXMA7Nf7F9YEp -06qUTOX7OKEwcJlRS9C730/6jQwNHSUxRP688npIl5F+egd7HC3ptkWI2exkgQvT -dtfhA2Ra+wu1rX6M1R577wg9WHMI7xu8zzo3MtopQniTo1nkhWuZ0IWkWyMJYHI6 -sMbzB3DfAgMBAAECggEADm7K2ghWoxwsstQh8j+DaLzx9/dIHIJV2PHdd5FGVeRQ -6gS7MswQmHrBUrtsb4VMZ2iz/AJqkw+jScpGldH3pCc4XELsSfxNHbseO4TNIqjr -4LOKOLYU4bRc3I+8KGXIAI5JzrucTJemEVUCDrte8cjbmqExt+zTyNpyxsapworF -v+vnSdv40d62f+cS1xvwB+ymLK/B/wZ/DemDCi8jsi7ou/M7l5xNCzjH4iMSLtOW -fgEhejIBG9miMJWPiVpTXE3tMdNuN3OsWc4XXm2t4VRovlZdu30Fax1xWB+Locsv -HlHKLOFc8g+jZh0TL2KCNjPffMcC7kHhW3afshpIsQKBgQDhyWUnkqd6FzbwIX70 -SnaMgKoUv5W/K5T+Sv/PA2CyN8Gu8ih/OsoNZSnI0uqe3XQIvvgN/Fq3wO1ttLzf -z5B6ZC7REfTgcR0190gihk6f5rtcj7d6Fy/oG2CE8sDSXgPnpEaBjvJVgN5v/U2s -HpVaidmHTyGLCfEszoeoy8jyrQKBgQDBX8caGylmzQLc6XNntZChlt3e18Nj8MPA -DxWLcoqgdDoofLDQAmLl+vPKyDmhQjos5eas1jgmVVEM4ge+MysaVezvuLBsSnOh -ihc0i63USU6i7YDE83DrCewCthpFHi/wW1S5FoCAzpVy8y99vwcqO4kOXcmf4O6Y -uW6sMsjvOwKBgQDbFtqB+MtsLCSSBF61W6AHHD5tna4H75lG2623yXZF2NanFLF5 -K6muL9DI3ujtOMQETJJUt9+rWJjLEEsJ/dYa/SV0l7D/LKOEnyuu3JZkkLaTzZzi -6qcA2bfhqdCzEKlHV99WjkfV8hNlpex9rLuOPB8JLh7FVONicBGxF/UojQKBgDXs -IlYaSuI6utilVKQP0kPtEPOKERc2VS+iRSy8hQGXR3xwwNFQSQm+f+sFCGT6VcSd -W0TI+6Fc2xwPj38vP465dTentbKM1E+wdSYW6SMwSfhO6ECDbfJsst5Sr2Kkt1N7 -9FUkfDLu6GfEfnK/KR1SurZB2u51R7NYyg7EnplvAoGAT0aTtOcck0oYN30g5mdf -efqXPwg2wAPYeiec49EbfnteQQKAkqNfJ9K69yE2naf6bw3/5mCBsq/cXeuaBMII -ylysUIRBqt2J0kWm2yCpFWR7H+Ilhdx9A7ZLCqYVt8e+vjO/BOI3cQDe2VPOLPSl -q/1PY4iJviGKddtmfClH3v4= ------END PRIVATE KEY-----"; - } - - protected function jwkKeyArray(): array - { - return [ - 'kty' => 'RSA', - 'alg' => 'RS256', - 'kid' => '066e52af-8884-4926-801d-032a276f9f2a', - 'use' => 'sig', - 'e' => 'AQAB', - 'n' => 'qo1OmfNKec5S2zQC4SP9DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvmzXL16c93Obn7G8x8A3ao6yN5qKO5S5-CETqOZfKN_g75Xlz7VsC3igOhgsXnPx6iiM6sbYbk0U_XpFaT84LXKI8VTIPUo7gTeZN1pTET__i9FlzAOzX-xfWBKdOqlEzl-zihMHCZUUvQu99P-o0MDR0lMUT-vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNkWvsLta1-jNUee-8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw3w', - ]; - } } \ No newline at end of file