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