5 use BookStack\Actions\ActivityType;
6 use BookStack\Auth\User;
7 use GuzzleHttp\Psr7\Request;
8 use GuzzleHttp\Psr7\Response;
9 use Tests\Helpers\OidcJwtHelper;
11 use Tests\TestResponse;
13 class OidcTest extends TestCase
15 protected string $keyFilePath;
18 protected function setUp(): void
21 // Set default config for OpenID Connect
23 $this->keyFile = tmpfile();
24 $this->keyFilePath = 'file://' . stream_get_meta_data($this->keyFile)['uri'];
25 file_put_contents($this->keyFilePath, OidcJwtHelper::publicPemKey());
28 'auth.method' => 'oidc',
29 'auth.defaults.guard' => 'oidc',
30 'oidc.name' => 'SingleSignOn-Testing',
31 'oidc.display_name_claims' => ['name'],
32 'oidc.client_id' => OidcJwtHelper::defaultClientId(),
33 'oidc.client_secret' => 'testpass',
34 'oidc.jwt_public_key' => $this->keyFilePath,
35 'oidc.issuer' => OidcJwtHelper::defaultIssuer(),
36 'oidc.authorization_endpoint' => 'https://oidc.local/auth',
37 'oidc.token_endpoint' => 'https://oidc.local/token',
38 'oidc.discover' => false,
39 'oidc.dump_user_details' => false,
43 protected function tearDown(): void
46 if (file_exists($this->keyFilePath)) {
47 unlink($this->keyFilePath);
51 public function test_login_option_shows_on_login_page()
53 $req = $this->get('/login');
54 $req->assertSeeText('SingleSignOn-Testing');
55 $req->assertElementExists('form[action$="/oidc/login"][method=POST] button');
58 public function test_oidc_routes_are_only_active_if_oidc_enabled()
60 config()->set(['auth.method' => 'standard']);
61 $routes = ['/login' => 'post', '/callback' => 'get'];
62 foreach ($routes as $uri => $method) {
63 $req = $this->call($method, '/oidc' . $uri);
64 $this->assertPermissionError($req);
68 public function test_forgot_password_routes_inaccessible()
70 $resp = $this->get('/password/email');
71 $this->assertPermissionError($resp);
73 $resp = $this->post('/password/email');
74 $this->assertPermissionError($resp);
76 $resp = $this->get('/password/reset/abc123');
77 $this->assertPermissionError($resp);
79 $resp = $this->post('/password/reset');
80 $this->assertPermissionError($resp);
83 public function test_standard_login_routes_inaccessible()
85 $resp = $this->post('/login');
86 $this->assertPermissionError($resp);
89 public function test_logout_route_functions()
91 $this->actingAs($this->getEditor());
92 $this->post('/logout');
93 $this->assertFalse(auth()->check());
96 public function test_user_invite_routes_inaccessible()
98 $resp = $this->get('/register/invite/abc123');
99 $this->assertPermissionError($resp);
101 $resp = $this->post('/register/invite/abc123');
102 $this->assertPermissionError($resp);
105 public function test_user_register_routes_inaccessible()
107 $resp = $this->get('/register');
108 $this->assertPermissionError($resp);
110 $resp = $this->post('/register');
111 $this->assertPermissionError($resp);
114 public function test_login()
116 $req = $this->post('/oidc/login');
117 $redirect = $req->headers->get('location');
119 $this->assertStringStartsWith('https://oidc.local/auth', $redirect, 'Login redirects to SSO location');
120 $this->assertFalse($this->isAuthenticated());
121 $this->assertStringContainsString('scope=openid%20profile%20email', $redirect);
122 $this->assertStringContainsString('client_id=' . OidcJwtHelper::defaultClientId(), $redirect);
123 $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $redirect);
126 public function test_login_success_flow()
129 $this->post('/oidc/login');
130 $state = session()->get('oidc_state');
132 $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([
133 'email' => 'benny@example.com',
134 'sub' => 'benny1010101',
137 // Callback from auth provider
138 // App calls token endpoint to get id token
139 $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
140 $resp->assertRedirect('/');
141 $this->assertCount(1, $transactions);
142 /** @var Request $tokenRequest */
143 $tokenRequest = $transactions[0]['request'];
144 $this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri());
145 $this->assertEquals('POST', $tokenRequest->getMethod());
146 $this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]);
147 $this->assertStringContainsString('grant_type=authorization_code', $tokenRequest->getBody());
148 $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody());
149 $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody());
151 $this->assertTrue(auth()->check());
152 $this->assertDatabaseHas('users', [
153 'email' => 'benny@example.com',
154 'external_auth_id' => 'benny1010101',
155 'email_confirmed' => false,
158 $user = User::query()->where('email', '=', 'benny@example.com')->first();
159 $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott");
162 public function test_callback_fails_if_no_state_present_or_matching()
164 $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
165 $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
167 $this->post('/oidc/login');
168 $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
169 $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
172 public function test_dump_user_details_option_outputs_as_expected()
174 config()->set('oidc.dump_user_details', true);
176 $resp = $this->runLogin([
177 'email' => 'benny@example.com',
181 $resp->assertStatus(200);
183 'email' => 'benny@example.com',
185 'iss' => OidcJwtHelper::defaultIssuer(),
186 'aud' => OidcJwtHelper::defaultClientId(),
188 $this->assertFalse(auth()->check());
191 public function test_auth_fails_if_no_email_exists_in_user_data()
198 $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system');
201 public function test_auth_fails_if_already_logged_in()
206 'email' => 'benny@example.com',
210 $this->assertSessionError('Already logged in');
213 public function test_auth_login_as_existing_user()
215 $editor = $this->getEditor();
216 $editor->external_auth_id = 'benny505';
219 $this->assertFalse(auth()->check());
222 'email' => 'benny@example.com',
226 $this->assertTrue(auth()->check());
227 $this->assertEquals($editor->id, auth()->user()->id);
230 public function test_auth_login_as_existing_user_email_with_different_auth_id_fails()
232 $editor = $this->getEditor();
233 $editor->external_auth_id = 'editor101';
236 $this->assertFalse(auth()->check());
238 $resp = $this->runLogin([
239 'email' => $editor->email,
242 $resp = $this->followRedirects($resp);
244 $resp->assertSeeText('A user with the email ' . $editor->email . ' already exists but with different credentials.');
245 $this->assertFalse(auth()->check());
248 public function test_auth_login_with_invalid_token_fails()
250 $resp = $this->runLogin([
253 $resp = $this->followRedirects($resp);
255 $resp->assertSeeText('ID token validate failed with error: Missing token subject value');
256 $this->assertFalse(auth()->check());
259 public function test_auth_login_with_autodiscovery()
261 $this->withAutodiscovery();
263 $transactions = &$this->mockHttpClient([
264 $this->getAutoDiscoveryResponse(),
265 $this->getJwksResponse(),
268 $this->assertFalse(auth()->check());
272 $this->assertTrue(auth()->check());
273 /** @var Request $discoverRequest */
274 $discoverRequest = $transactions[0]['request'];
275 /** @var Request $discoverRequest */
276 $keysRequest = $transactions[1]['request'];
278 $this->assertEquals('GET', $keysRequest->getMethod());
279 $this->assertEquals('GET', $discoverRequest->getMethod());
280 $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri());
281 $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/oidc/keys', $keysRequest->getUri());
284 public function test_auth_fails_if_autodiscovery_fails()
286 $this->withAutodiscovery();
287 $this->mockHttpClient([
288 new Response(404, [], 'Not found'),
291 $resp = $this->followRedirects($this->runLogin());
292 $this->assertFalse(auth()->check());
293 $resp->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
296 public function test_autodiscovery_calls_are_cached()
298 $this->withAutodiscovery();
300 $transactions = &$this->mockHttpClient([
301 $this->getAutoDiscoveryResponse(),
302 $this->getJwksResponse(),
303 $this->getAutoDiscoveryResponse([
304 'issuer' => 'https://auto.example.com',
306 $this->getJwksResponse(),
310 $this->post('/oidc/login');
311 $this->assertCount(2, $transactions);
312 // Second run, hits cache
313 $this->post('/oidc/login');
314 $this->assertCount(2, $transactions);
316 // Third run, different issuer, new cache key
317 config()->set(['oidc.issuer' => 'https://auto.example.com']);
318 $this->post('/oidc/login');
319 $this->assertCount(4, $transactions);
322 public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_alg_property()
324 $this->withAutodiscovery();
326 $keyArray = OidcJwtHelper::publicJwkKeyArray();
327 unset($keyArray['alg']);
329 $this->mockHttpClient([
330 $this->getAutoDiscoveryResponse(),
332 'Content-Type' => 'application/json',
333 'Cache-Control' => 'no-cache, no-store',
334 'Pragma' => 'no-cache',
342 $this->assertFalse(auth()->check());
344 $this->assertTrue(auth()->check());
347 protected function withAutodiscovery()
350 'oidc.issuer' => OidcJwtHelper::defaultIssuer(),
351 'oidc.discover' => true,
352 'oidc.authorization_endpoint' => null,
353 'oidc.token_endpoint' => null,
354 'oidc.jwt_public_key' => null,
358 protected function runLogin($claimOverrides = []): TestResponse
360 $this->post('/oidc/login');
361 $state = session()->get('oidc_state');
362 $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]);
364 return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
367 protected function getAutoDiscoveryResponse($responseOverrides = []): Response
369 return new Response(200, [
370 'Content-Type' => 'application/json',
371 'Cache-Control' => 'no-cache, no-store',
372 'Pragma' => 'no-cache',
373 ], json_encode(array_merge([
374 'token_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/token',
375 'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize',
376 'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys',
377 'issuer' => OidcJwtHelper::defaultIssuer(),
378 ], $responseOverrides)));
381 protected function getJwksResponse(): Response
383 return new Response(200, [
384 'Content-Type' => 'application/json',
385 'Cache-Control' => 'no-cache, no-store',
386 'Pragma' => 'no-cache',
389 OidcJwtHelper::publicJwkKeyArray(),
394 protected function getMockAuthorizationResponse($claimOverrides = []): Response
396 return new Response(200, [
397 'Content-Type' => 'application/json',
398 'Cache-Control' => 'no-cache, no-store',
399 'Pragma' => 'no-cache',
401 'access_token' => 'abc123',
402 'token_type' => 'Bearer',
403 'expires_in' => 3600,
404 'id_token' => OidcJwtHelper::idToken($claimOverrides),