]> BookStack Code Mirror - bookstack/blob - tests/Auth/OidcTest.php
41727e7b71d7223a00af51f03f0735334875ab35
[bookstack] / tests / Auth / OidcTest.php
1 <?php
2
3 namespace Tests\Auth;
4
5 use BookStack\Actions\ActivityType;
6 use BookStack\Auth\Role;
7 use BookStack\Auth\User;
8 use BookStack\Facades\Theme;
9 use BookStack\Theming\ThemeEvents;
10 use GuzzleHttp\Psr7\Request;
11 use GuzzleHttp\Psr7\Response;
12 use Illuminate\Testing\TestResponse;
13 use Tests\Helpers\OidcJwtHelper;
14 use Tests\TestCase;
15
16 class OidcTest extends TestCase
17 {
18     protected string $keyFilePath;
19     protected $keyFile;
20
21     protected function setUp(): void
22     {
23         parent::setUp();
24         // Set default config for OpenID Connect
25
26         $this->keyFile = tmpfile();
27         $this->keyFilePath = 'file://' . stream_get_meta_data($this->keyFile)['uri'];
28         file_put_contents($this->keyFilePath, OidcJwtHelper::publicPemKey());
29
30         config()->set([
31             'auth.method'                 => 'oidc',
32             'auth.defaults.guard'         => 'oidc',
33             'oidc.name'                   => 'SingleSignOn-Testing',
34             'oidc.display_name_claims'    => ['name'],
35             'oidc.client_id'              => OidcJwtHelper::defaultClientId(),
36             'oidc.client_secret'          => 'testpass',
37             'oidc.jwt_public_key'         => $this->keyFilePath,
38             'oidc.issuer'                 => OidcJwtHelper::defaultIssuer(),
39             'oidc.authorization_endpoint' => 'https://oidc.local/auth',
40             'oidc.token_endpoint'         => 'https://oidc.local/token',
41             'oidc.discover'               => false,
42             'oidc.dump_user_details'      => false,
43             'oidc.additional_scopes'      => '',
44             'oidc.user_to_groups'         => false,
45             'oidc.groups_claim'           => 'group',
46             'oidc.remove_from_groups'     => false,
47             'oidc.external_id_claim'      => 'sub',
48         ]);
49     }
50
51     protected function tearDown(): void
52     {
53         parent::tearDown();
54         if (file_exists($this->keyFilePath)) {
55             unlink($this->keyFilePath);
56         }
57     }
58
59     public function test_login_option_shows_on_login_page()
60     {
61         $req = $this->get('/login');
62         $req->assertSeeText('SingleSignOn-Testing');
63         $this->withHtml($req)->assertElementExists('form[action$="/oidc/login"][method=POST] button');
64     }
65
66     public function test_oidc_routes_are_only_active_if_oidc_enabled()
67     {
68         config()->set(['auth.method' => 'standard']);
69         $routes = ['/login' => 'post', '/callback' => 'get'];
70         foreach ($routes as $uri => $method) {
71             $req = $this->call($method, '/oidc' . $uri);
72             $this->assertPermissionError($req);
73         }
74     }
75
76     public function test_forgot_password_routes_inaccessible()
77     {
78         $resp = $this->get('/password/email');
79         $this->assertPermissionError($resp);
80
81         $resp = $this->post('/password/email');
82         $this->assertPermissionError($resp);
83
84         $resp = $this->get('/password/reset/abc123');
85         $this->assertPermissionError($resp);
86
87         $resp = $this->post('/password/reset');
88         $this->assertPermissionError($resp);
89     }
90
91     public function test_standard_login_routes_inaccessible()
92     {
93         $resp = $this->post('/login');
94         $this->assertPermissionError($resp);
95     }
96
97     public function test_logout_route_functions()
98     {
99         $this->actingAs($this->users->editor());
100         $this->post('/logout');
101         $this->assertFalse(auth()->check());
102     }
103
104     public function test_user_invite_routes_inaccessible()
105     {
106         $resp = $this->get('/register/invite/abc123');
107         $this->assertPermissionError($resp);
108
109         $resp = $this->post('/register/invite/abc123');
110         $this->assertPermissionError($resp);
111     }
112
113     public function test_user_register_routes_inaccessible()
114     {
115         $resp = $this->get('/register');
116         $this->assertPermissionError($resp);
117
118         $resp = $this->post('/register');
119         $this->assertPermissionError($resp);
120     }
121
122     public function test_login()
123     {
124         $req = $this->post('/oidc/login');
125         $redirect = $req->headers->get('location');
126
127         $this->assertStringStartsWith('https://oidc.local/auth', $redirect, 'Login redirects to SSO location');
128         $this->assertFalse($this->isAuthenticated());
129         $this->assertStringContainsString('scope=openid%20profile%20email', $redirect);
130         $this->assertStringContainsString('client_id=' . OidcJwtHelper::defaultClientId(), $redirect);
131         $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $redirect);
132     }
133
134     public function test_login_success_flow()
135     {
136         // Start auth
137         $this->post('/oidc/login');
138         $state = session()->get('oidc_state');
139
140         $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([
141             'email' => '[email protected]',
142             'sub'   => 'benny1010101',
143         ])]);
144
145         // Callback from auth provider
146         // App calls token endpoint to get id token
147         $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
148         $resp->assertRedirect('/');
149         $this->assertCount(1, $transactions);
150         /** @var Request $tokenRequest */
151         $tokenRequest = $transactions[0]['request'];
152         $this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri());
153         $this->assertEquals('POST', $tokenRequest->getMethod());
154         $this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]);
155         $this->assertStringContainsString('grant_type=authorization_code', $tokenRequest->getBody());
156         $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody());
157         $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody());
158
159         $this->assertTrue(auth()->check());
160         $this->assertDatabaseHas('users', [
161             'email'            => '[email protected]',
162             'external_auth_id' => 'benny1010101',
163             'email_confirmed'  => false,
164         ]);
165
166         $user = User::query()->where('email', '=', '[email protected]')->first();
167         $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott");
168     }
169
170     public function test_login_uses_custom_additional_scopes_if_defined()
171     {
172         config()->set([
173             'oidc.additional_scopes' => 'groups, badgers',
174         ]);
175
176         $redirect = $this->post('/oidc/login')->headers->get('location');
177
178         $this->assertStringContainsString('scope=openid%20profile%20email%20groups%20badgers', $redirect);
179     }
180
181     public function test_callback_fails_if_no_state_present_or_matching()
182     {
183         $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
184         $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
185
186         $this->post('/oidc/login');
187         $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
188         $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
189     }
190
191     public function test_dump_user_details_option_outputs_as_expected()
192     {
193         config()->set('oidc.dump_user_details', true);
194
195         $resp = $this->runLogin([
196             'email' => '[email protected]',
197             'sub'   => 'benny505',
198         ]);
199
200         $resp->assertStatus(200);
201         $resp->assertJson([
202             'email' => '[email protected]',
203             'sub'   => 'benny505',
204             'iss'   => OidcJwtHelper::defaultIssuer(),
205             'aud'   => OidcJwtHelper::defaultClientId(),
206         ]);
207         $this->assertFalse(auth()->check());
208     }
209
210     public function test_auth_fails_if_no_email_exists_in_user_data()
211     {
212         $this->runLogin([
213             'email' => '',
214             'sub'   => 'benny505',
215         ]);
216
217         $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system');
218     }
219
220     public function test_auth_fails_if_already_logged_in()
221     {
222         $this->asEditor();
223
224         $this->runLogin([
225             'email' => '[email protected]',
226             'sub'   => 'benny505',
227         ]);
228
229         $this->assertSessionError('Already logged in');
230     }
231
232     public function test_auth_login_as_existing_user()
233     {
234         $editor = $this->users->editor();
235         $editor->external_auth_id = 'benny505';
236         $editor->save();
237
238         $this->assertFalse(auth()->check());
239
240         $this->runLogin([
241             'email' => '[email protected]',
242             'sub'   => 'benny505',
243         ]);
244
245         $this->assertTrue(auth()->check());
246         $this->assertEquals($editor->id, auth()->user()->id);
247     }
248
249     public function test_auth_login_as_existing_user_email_with_different_auth_id_fails()
250     {
251         $editor = $this->users->editor();
252         $editor->external_auth_id = 'editor101';
253         $editor->save();
254
255         $this->assertFalse(auth()->check());
256
257         $resp = $this->runLogin([
258             'email' => $editor->email,
259             'sub'   => 'benny505',
260         ]);
261         $resp = $this->followRedirects($resp);
262
263         $resp->assertSeeText('A user with the email ' . $editor->email . ' already exists but with different credentials.');
264         $this->assertFalse(auth()->check());
265     }
266
267     public function test_auth_login_with_invalid_token_fails()
268     {
269         $resp = $this->runLogin([
270             'sub' => null,
271         ]);
272         $resp = $this->followRedirects($resp);
273
274         $resp->assertSeeText('ID token validate failed with error: Missing token subject value');
275         $this->assertFalse(auth()->check());
276     }
277
278     public function test_auth_login_with_autodiscovery()
279     {
280         $this->withAutodiscovery();
281
282         $transactions = &$this->mockHttpClient([
283             $this->getAutoDiscoveryResponse(),
284             $this->getJwksResponse(),
285         ]);
286
287         $this->assertFalse(auth()->check());
288
289         $this->runLogin();
290
291         $this->assertTrue(auth()->check());
292         /** @var Request $discoverRequest */
293         $discoverRequest = $transactions[0]['request'];
294         /** @var Request $discoverRequest */
295         $keysRequest = $transactions[1]['request'];
296
297         $this->assertEquals('GET', $keysRequest->getMethod());
298         $this->assertEquals('GET', $discoverRequest->getMethod());
299         $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri());
300         $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/oidc/keys', $keysRequest->getUri());
301     }
302
303     public function test_auth_fails_if_autodiscovery_fails()
304     {
305         $this->withAutodiscovery();
306         $this->mockHttpClient([
307             new Response(404, [], 'Not found'),
308         ]);
309
310         $resp = $this->followRedirects($this->runLogin());
311         $this->assertFalse(auth()->check());
312         $resp->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
313     }
314
315     public function test_autodiscovery_calls_are_cached()
316     {
317         $this->withAutodiscovery();
318
319         $transactions = &$this->mockHttpClient([
320             $this->getAutoDiscoveryResponse(),
321             $this->getJwksResponse(),
322             $this->getAutoDiscoveryResponse([
323                 'issuer' => 'https://auto.example.com',
324             ]),
325             $this->getJwksResponse(),
326         ]);
327
328         // Initial run
329         $this->post('/oidc/login');
330         $this->assertCount(2, $transactions);
331         // Second run, hits cache
332         $this->post('/oidc/login');
333         $this->assertCount(2, $transactions);
334
335         // Third run, different issuer, new cache key
336         config()->set(['oidc.issuer' => 'https://auto.example.com']);
337         $this->post('/oidc/login');
338         $this->assertCount(4, $transactions);
339     }
340
341     public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_alg_property()
342     {
343         $this->withAutodiscovery();
344
345         $keyArray = OidcJwtHelper::publicJwkKeyArray();
346         unset($keyArray['alg']);
347
348         $this->mockHttpClient([
349             $this->getAutoDiscoveryResponse(),
350             new Response(200, [
351                 'Content-Type'  => 'application/json',
352                 'Cache-Control' => 'no-cache, no-store',
353                 'Pragma'        => 'no-cache',
354             ], json_encode([
355                 'keys' => [
356                     $keyArray,
357                 ],
358             ])),
359         ]);
360
361         $this->assertFalse(auth()->check());
362         $this->runLogin();
363         $this->assertTrue(auth()->check());
364     }
365
366     public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_use_property()
367     {
368         // Based on reading the OIDC discovery spec:
369         // > This contains the signing key(s) the RP uses to validate signatures from the OP. The JWK Set MAY also
370         // > contain the Server's encryption key(s), which are used by RPs to encrypt requests to the Server. When
371         // > both signing and encryption keys are made available, a use (Key Use) parameter value is REQUIRED for all
372         // > keys in the referenced JWK Set to indicate each key's intended usage.
373         // We can assume that keys without use are intended for signing.
374         $this->withAutodiscovery();
375
376         $keyArray = OidcJwtHelper::publicJwkKeyArray();
377         unset($keyArray['use']);
378
379         $this->mockHttpClient([
380             $this->getAutoDiscoveryResponse(),
381             new Response(200, [
382                 'Content-Type'  => 'application/json',
383                 'Cache-Control' => 'no-cache, no-store',
384                 'Pragma'        => 'no-cache',
385             ], json_encode([
386                 'keys' => [
387                     $keyArray,
388                 ],
389             ])),
390         ]);
391
392         $this->assertFalse(auth()->check());
393         $this->runLogin();
394         $this->assertTrue(auth()->check());
395     }
396
397     public function test_auth_uses_configured_external_id_claim_option()
398     {
399         config()->set([
400             'oidc.external_id_claim' => 'super_awesome_id',
401         ]);
402
403         $resp = $this->runLogin([
404             'email'            => '[email protected]',
405             'sub'              => 'benny1010101',
406             'super_awesome_id' => 'xXBennyTheGeezXx',
407         ]);
408         $resp->assertRedirect('/');
409
410         /** @var User $user */
411         $user = User::query()->where('email', '=', '[email protected]')->first();
412         $this->assertEquals('xXBennyTheGeezXx', $user->external_auth_id);
413     }
414
415     public function test_login_group_sync()
416     {
417         config()->set([
418             'oidc.user_to_groups'     => true,
419             'oidc.groups_claim'       => 'groups',
420             'oidc.remove_from_groups' => false,
421         ]);
422         $roleA = Role::factory()->create(['display_name' => 'Wizards']);
423         $roleB = Role::factory()->create(['display_name' => 'ZooFolks', 'external_auth_id' => 'zookeepers']);
424         $roleC = Role::factory()->create(['display_name' => 'Another Role']);
425
426         $resp = $this->runLogin([
427             'email'  => '[email protected]',
428             'sub'    => 'benny1010101',
429             'groups' => ['Wizards', 'Zookeepers'],
430         ]);
431         $resp->assertRedirect('/');
432
433         /** @var User $user */
434         $user = User::query()->where('email', '=', '[email protected]')->first();
435
436         $this->assertTrue($user->hasRole($roleA->id));
437         $this->assertTrue($user->hasRole($roleB->id));
438         $this->assertFalse($user->hasRole($roleC->id));
439     }
440
441     public function test_login_group_sync_with_nested_groups_in_token()
442     {
443         config()->set([
444             'oidc.user_to_groups'     => true,
445             'oidc.groups_claim'       => 'my.custom.groups.attr',
446             'oidc.remove_from_groups' => false,
447         ]);
448         $roleA = Role::factory()->create(['display_name' => 'Wizards']);
449
450         $resp = $this->runLogin([
451             'email'  => '[email protected]',
452             'sub'    => 'benny1010101',
453             'my'     => [
454                 'custom' => [
455                     'groups' => [
456                         'attr' => ['Wizards'],
457                     ],
458                 ],
459             ],
460         ]);
461         $resp->assertRedirect('/');
462
463         /** @var User $user */
464         $user = User::query()->where('email', '=', '[email protected]')->first();
465         $this->assertTrue($user->hasRole($roleA->id));
466     }
467
468     public function test_oidc_id_token_pre_validate_theme_event_without_return()
469     {
470         $args = [];
471         $callback = function (...$eventArgs) use (&$args) {
472             $args = $eventArgs;
473         };
474         Theme::listen(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $callback);
475
476         $resp = $this->runLogin([
477             'email' => '[email protected]',
478             'sub'   => 'benny1010101',
479             'name'  => 'Benny',
480         ]);
481         $resp->assertRedirect('/');
482
483         $this->assertDatabaseHas('users', [
484             'external_auth_id' => 'benny1010101',
485         ]);
486
487         $this->assertArrayHasKey('iss', $args[0]);
488         $this->assertArrayHasKey('sub', $args[0]);
489         $this->assertEquals('Benny', $args[0]['name']);
490         $this->assertEquals('benny1010101', $args[0]['sub']);
491
492         $this->assertArrayHasKey('access_token', $args[1]);
493         $this->assertArrayHasKey('expires_in', $args[1]);
494         $this->assertArrayHasKey('refresh_token', $args[1]);
495     }
496
497     public function test_oidc_id_token_pre_validate_theme_event_with_return()
498     {
499         $callback = function (...$eventArgs) {
500             return array_merge($eventArgs[0], [
501                 'email' => '[email protected]',
502                 'sub' => 'lenny1010101',
503                 'name' => 'Lenny',
504             ]);
505         };
506         Theme::listen(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $callback);
507
508         $resp = $this->runLogin([
509             'email' => '[email protected]',
510             'sub'   => 'benny1010101',
511             'name'  => 'Benny',
512         ]);
513         $resp->assertRedirect('/');
514
515         $this->assertDatabaseHas('users', [
516             'email' => '[email protected]',
517             'external_auth_id' => 'lenny1010101',
518             'name' => 'Lenny',
519         ]);
520     }
521
522     protected function withAutodiscovery()
523     {
524         config()->set([
525             'oidc.issuer'                 => OidcJwtHelper::defaultIssuer(),
526             'oidc.discover'               => true,
527             'oidc.authorization_endpoint' => null,
528             'oidc.token_endpoint'         => null,
529             'oidc.jwt_public_key'         => null,
530         ]);
531     }
532
533     protected function runLogin($claimOverrides = []): TestResponse
534     {
535         $this->post('/oidc/login');
536         $state = session()->get('oidc_state');
537         $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]);
538
539         return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
540     }
541
542     protected function getAutoDiscoveryResponse($responseOverrides = []): Response
543     {
544         return new Response(200, [
545             'Content-Type'  => 'application/json',
546             'Cache-Control' => 'no-cache, no-store',
547             'Pragma'        => 'no-cache',
548         ], json_encode(array_merge([
549             'token_endpoint'         => OidcJwtHelper::defaultIssuer() . '/oidc/token',
550             'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize',
551             'jwks_uri'               => OidcJwtHelper::defaultIssuer() . '/oidc/keys',
552             'issuer'                 => OidcJwtHelper::defaultIssuer(),
553         ], $responseOverrides)));
554     }
555
556     protected function getJwksResponse(): Response
557     {
558         return new Response(200, [
559             'Content-Type'  => 'application/json',
560             'Cache-Control' => 'no-cache, no-store',
561             'Pragma'        => 'no-cache',
562         ], json_encode([
563             'keys' => [
564                 OidcJwtHelper::publicJwkKeyArray(),
565             ],
566         ]));
567     }
568
569     protected function getMockAuthorizationResponse($claimOverrides = []): Response
570     {
571         return new Response(200, [
572             'Content-Type'  => 'application/json',
573             'Cache-Control' => 'no-cache, no-store',
574             'Pragma'        => 'no-cache',
575         ], json_encode([
576             'access_token' => 'abc123',
577             'token_type'   => 'Bearer',
578             'expires_in'   => 3600,
579             'id_token'     => OidcJwtHelper::idToken($claimOverrides),
580         ]));
581     }
582 }