]> BookStack Code Mirror - bookstack/blob - tests/Auth/OidcTest.php
Removed use of HttpFetcher
[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_login_group_sync()
412     {
413         config()->set([
414             'oidc.user_to_groups'     => true,
415             'oidc.groups_claim'       => 'groups',
416             'oidc.remove_from_groups' => false,
417         ]);
418         $roleA = Role::factory()->create(['display_name' => 'Wizards']);
419         $roleB = Role::factory()->create(['display_name' => 'ZooFolks', 'external_auth_id' => 'zookeepers']);
420         $roleC = Role::factory()->create(['display_name' => 'Another Role']);
421
422         $resp = $this->runLogin([
423             'email'  => '[email protected]',
424             'sub'    => 'benny1010101',
425             'groups' => ['Wizards', 'Zookeepers'],
426         ]);
427         $resp->assertRedirect('/');
428
429         /** @var User $user */
430         $user = User::query()->where('email', '=', '[email protected]')->first();
431
432         $this->assertTrue($user->hasRole($roleA->id));
433         $this->assertTrue($user->hasRole($roleB->id));
434         $this->assertFalse($user->hasRole($roleC->id));
435     }
436
437     public function test_login_group_sync_with_nested_groups_in_token()
438     {
439         config()->set([
440             'oidc.user_to_groups'     => true,
441             'oidc.groups_claim'       => 'my.custom.groups.attr',
442             'oidc.remove_from_groups' => false,
443         ]);
444         $roleA = Role::factory()->create(['display_name' => 'Wizards']);
445
446         $resp = $this->runLogin([
447             'email'  => '[email protected]',
448             'sub'    => 'benny1010101',
449             'my'     => [
450                 'custom' => [
451                     'groups' => [
452                         'attr' => ['Wizards'],
453                     ],
454                 ],
455             ],
456         ]);
457         $resp->assertRedirect('/');
458
459         /** @var User $user */
460         $user = User::query()->where('email', '=', '[email protected]')->first();
461         $this->assertTrue($user->hasRole($roleA->id));
462     }
463
464     public function test_oidc_id_token_pre_validate_theme_event_without_return()
465     {
466         $args = [];
467         $callback = function (...$eventArgs) use (&$args) {
468             $args = $eventArgs;
469         };
470         Theme::listen(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $callback);
471
472         $resp = $this->runLogin([
473             'email' => '[email protected]',
474             'sub'   => 'benny1010101',
475             'name'  => 'Benny',
476         ]);
477         $resp->assertRedirect('/');
478
479         $this->assertDatabaseHas('users', [
480             'external_auth_id' => 'benny1010101',
481         ]);
482
483         $this->assertArrayHasKey('iss', $args[0]);
484         $this->assertArrayHasKey('sub', $args[0]);
485         $this->assertEquals('Benny', $args[0]['name']);
486         $this->assertEquals('benny1010101', $args[0]['sub']);
487
488         $this->assertArrayHasKey('access_token', $args[1]);
489         $this->assertArrayHasKey('expires_in', $args[1]);
490         $this->assertArrayHasKey('refresh_token', $args[1]);
491     }
492
493     public function test_oidc_id_token_pre_validate_theme_event_with_return()
494     {
495         $callback = function (...$eventArgs) {
496             return array_merge($eventArgs[0], [
497                 'email' => '[email protected]',
498                 'sub' => 'lenny1010101',
499                 'name' => 'Lenny',
500             ]);
501         };
502         Theme::listen(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $callback);
503
504         $resp = $this->runLogin([
505             'email' => '[email protected]',
506             'sub'   => 'benny1010101',
507             'name'  => 'Benny',
508         ]);
509         $resp->assertRedirect('/');
510
511         $this->assertDatabaseHas('users', [
512             'email' => '[email protected]',
513             'external_auth_id' => 'lenny1010101',
514             'name' => 'Lenny',
515         ]);
516     }
517
518     protected function withAutodiscovery()
519     {
520         config()->set([
521             'oidc.issuer'                 => OidcJwtHelper::defaultIssuer(),
522             'oidc.discover'               => true,
523             'oidc.authorization_endpoint' => null,
524             'oidc.token_endpoint'         => null,
525             'oidc.jwt_public_key'         => null,
526         ]);
527     }
528
529     protected function runLogin($claimOverrides = []): TestResponse
530     {
531         $this->post('/oidc/login');
532         $state = session()->get('oidc_state');
533         $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]);
534
535         return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
536     }
537
538     protected function getAutoDiscoveryResponse($responseOverrides = []): Response
539     {
540         return new Response(200, [
541             'Content-Type'  => 'application/json',
542             'Cache-Control' => 'no-cache, no-store',
543             'Pragma'        => 'no-cache',
544         ], json_encode(array_merge([
545             'token_endpoint'         => OidcJwtHelper::defaultIssuer() . '/oidc/token',
546             'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize',
547             'jwks_uri'               => OidcJwtHelper::defaultIssuer() . '/oidc/keys',
548             'issuer'                 => OidcJwtHelper::defaultIssuer(),
549         ], $responseOverrides)));
550     }
551
552     protected function getJwksResponse(): Response
553     {
554         return new Response(200, [
555             'Content-Type'  => 'application/json',
556             'Cache-Control' => 'no-cache, no-store',
557             'Pragma'        => 'no-cache',
558         ], json_encode([
559             'keys' => [
560                 OidcJwtHelper::publicJwkKeyArray(),
561             ],
562         ]));
563     }
564
565     protected function getMockAuthorizationResponse($claimOverrides = []): Response
566     {
567         return new Response(200, [
568             'Content-Type'  => 'application/json',
569             'Cache-Control' => 'no-cache, no-store',
570             'Pragma'        => 'no-cache',
571         ], json_encode([
572             'access_token' => 'abc123',
573             'token_type'   => 'Bearer',
574             'expires_in'   => 3600,
575             'id_token'     => OidcJwtHelper::idToken($claimOverrides),
576         ]));
577     }
578 }