]> BookStack Code Mirror - bookstack/blob - tests/Auth/OidcTest.php
OIDC: Added testing coverage for picture fetching
[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.userinfo_endpoint'      => 'https://oidc.local/userinfo',
41             'oidc.discover'               => false,
42             'oidc.dump_user_details'      => false,
43             'oidc.additional_scopes'      => '',
44             'odic.fetch_avatar'           => false,
45             'oidc.user_to_groups'         => false,
46             'oidc.groups_claim'           => 'group',
47             'oidc.remove_from_groups'     => false,
48             'oidc.external_id_claim'      => 'sub',
49             'oidc.end_session_endpoint'   => false,
50         ]);
51     }
52
53     protected function tearDown(): void
54     {
55         parent::tearDown();
56         if (file_exists($this->keyFilePath)) {
57             unlink($this->keyFilePath);
58         }
59     }
60
61     public function test_login_option_shows_on_login_page()
62     {
63         $req = $this->get('/login');
64         $req->assertSeeText('SingleSignOn-Testing');
65         $this->withHtml($req)->assertElementExists('form[action$="/oidc/login"][method=POST] button');
66     }
67
68     public function test_oidc_routes_are_only_active_if_oidc_enabled()
69     {
70         config()->set(['auth.method' => 'standard']);
71         $routes = ['/login' => 'post', '/callback' => 'get'];
72         foreach ($routes as $uri => $method) {
73             $req = $this->call($method, '/oidc' . $uri);
74             $this->assertPermissionError($req);
75         }
76     }
77
78     public function test_forgot_password_routes_inaccessible()
79     {
80         $resp = $this->get('/password/email');
81         $this->assertPermissionError($resp);
82
83         $resp = $this->post('/password/email');
84         $this->assertPermissionError($resp);
85
86         $resp = $this->get('/password/reset/abc123');
87         $this->assertPermissionError($resp);
88
89         $resp = $this->post('/password/reset');
90         $this->assertPermissionError($resp);
91     }
92
93     public function test_standard_login_routes_inaccessible()
94     {
95         $resp = $this->post('/login');
96         $this->assertPermissionError($resp);
97     }
98
99     public function test_logout_route_functions()
100     {
101         $this->actingAs($this->users->editor());
102         $this->post('/logout');
103         $this->assertFalse(auth()->check());
104     }
105
106     public function test_user_invite_routes_inaccessible()
107     {
108         $resp = $this->get('/register/invite/abc123');
109         $this->assertPermissionError($resp);
110
111         $resp = $this->post('/register/invite/abc123');
112         $this->assertPermissionError($resp);
113     }
114
115     public function test_user_register_routes_inaccessible()
116     {
117         $resp = $this->get('/register');
118         $this->assertPermissionError($resp);
119
120         $resp = $this->post('/register');
121         $this->assertPermissionError($resp);
122     }
123
124     public function test_login()
125     {
126         $req = $this->post('/oidc/login');
127         $redirect = $req->headers->get('location');
128
129         $this->assertStringStartsWith('https://oidc.local/auth', $redirect, 'Login redirects to SSO location');
130         $this->assertFalse($this->isAuthenticated());
131         $this->assertStringContainsString('scope=openid%20profile%20email', $redirect);
132         $this->assertStringContainsString('client_id=' . OidcJwtHelper::defaultClientId(), $redirect);
133         $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $redirect);
134     }
135
136     public function test_login_success_flow()
137     {
138         // Start auth
139         $this->post('/oidc/login');
140         $state = session()->get('oidc_state');
141
142         $transactions = $this->mockHttpClient([$this->getMockAuthorizationResponse([
143             'email' => '[email protected]',
144             'sub'   => 'benny1010101',
145         ])]);
146
147         // Callback from auth provider
148         // App calls token endpoint to get id token
149         $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
150         $resp->assertRedirect('/');
151         $this->assertEquals(1, $transactions->requestCount());
152         $tokenRequest = $transactions->latestRequest();
153         $this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri());
154         $this->assertEquals('POST', $tokenRequest->getMethod());
155         $this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]);
156         $this->assertStringContainsString('grant_type=authorization_code', $tokenRequest->getBody());
157         $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody());
158         $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody());
159
160         $this->assertTrue(auth()->check());
161         $this->assertDatabaseHas('users', [
162             'email'            => '[email protected]',
163             'external_auth_id' => 'benny1010101',
164             'email_confirmed'  => false,
165         ]);
166
167         $user = User::query()->where('email', '=', '[email protected]')->first();
168         $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott");
169     }
170
171     public function test_login_uses_custom_additional_scopes_if_defined()
172     {
173         config()->set([
174             'oidc.additional_scopes' => 'groups, badgers',
175         ]);
176
177         $redirect = $this->post('/oidc/login')->headers->get('location');
178
179         $this->assertStringContainsString('scope=openid%20profile%20email%20groups%20badgers', $redirect);
180     }
181
182     public function test_callback_fails_if_no_state_present_or_matching()
183     {
184         $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
185         $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
186
187         $this->post('/oidc/login');
188         $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
189         $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
190     }
191
192     public function test_dump_user_details_option_outputs_as_expected()
193     {
194         config()->set('oidc.dump_user_details', true);
195
196         $resp = $this->runLogin([
197             'email' => '[email protected]',
198             'sub'   => 'benny505',
199         ]);
200
201         $resp->assertStatus(200);
202         $resp->assertJson([
203             'email' => '[email protected]',
204             'sub'   => 'benny505',
205             'iss'   => OidcJwtHelper::defaultIssuer(),
206             'aud'   => OidcJwtHelper::defaultClientId(),
207         ]);
208         $this->assertFalse(auth()->check());
209     }
210
211     public function test_auth_fails_if_no_email_exists_in_user_data()
212     {
213         config()->set('oidc.userinfo_endpoint', null);
214
215         $this->runLogin([
216             'email' => '',
217             'sub'   => 'benny505',
218         ]);
219
220         $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system');
221     }
222
223     public function test_auth_fails_if_already_logged_in()
224     {
225         $this->asEditor();
226
227         $this->runLogin([
228             'email' => '[email protected]',
229             'sub'   => 'benny505',
230         ]);
231
232         $this->assertSessionError('Already logged in');
233     }
234
235     public function test_auth_login_as_existing_user()
236     {
237         $editor = $this->users->editor();
238         $editor->external_auth_id = 'benny505';
239         $editor->save();
240
241         $this->assertFalse(auth()->check());
242
243         $this->runLogin([
244             'email' => '[email protected]',
245             'sub'   => 'benny505',
246         ]);
247
248         $this->assertTrue(auth()->check());
249         $this->assertEquals($editor->id, auth()->user()->id);
250     }
251
252     public function test_auth_login_as_existing_user_email_with_different_auth_id_fails()
253     {
254         $editor = $this->users->editor();
255         $editor->external_auth_id = 'editor101';
256         $editor->save();
257
258         $this->assertFalse(auth()->check());
259
260         $resp = $this->runLogin([
261             'email' => $editor->email,
262             'sub'   => 'benny505',
263         ]);
264         $resp = $this->followRedirects($resp);
265
266         $resp->assertSeeText('A user with the email ' . $editor->email . ' already exists but with different credentials.');
267         $this->assertFalse(auth()->check());
268     }
269
270     public function test_auth_login_with_invalid_token_fails()
271     {
272         $resp = $this->runLogin([
273             'sub' => null,
274         ]);
275         $resp = $this->followRedirects($resp);
276
277         $resp->assertSeeText('ID token validation failed with error: Missing token subject value');
278         $this->assertFalse(auth()->check());
279     }
280
281     public function test_auth_fails_if_endpoints_start_with_https()
282     {
283         $endpointConfigKeys = [
284             'oidc.token_endpoint' => 'tokenEndpoint',
285             'oidc.authorization_endpoint' => 'authorizationEndpoint',
286             'oidc.userinfo_endpoint' => 'userinfoEndpoint',
287         ];
288
289         foreach ($endpointConfigKeys as $endpointConfigKey => $endpointName) {
290             $logger = $this->withTestLogger();
291             $original = config()->get($endpointConfigKey);
292             $new = str_replace('https://', 'http://', $original);
293             config()->set($endpointConfigKey, $new);
294
295             $this->withoutExceptionHandling();
296             $err = null;
297             try {
298                 $resp = $this->runLogin();
299                 $resp->assertRedirect('/login');
300             } catch (\Exception $exception) {
301                 $err = $exception;
302             }
303             $this->assertEquals("Endpoint value for \"{$endpointName}\" must start with https://", $err->getMessage());
304
305             config()->set($endpointConfigKey, $original);
306         }
307     }
308
309     public function test_auth_login_with_autodiscovery()
310     {
311         $this->withAutodiscovery();
312
313         $transactions = $this->mockHttpClient([
314             $this->getAutoDiscoveryResponse(),
315             $this->getJwksResponse(),
316         ]);
317
318         $this->assertFalse(auth()->check());
319
320         $this->runLogin();
321
322         $this->assertTrue(auth()->check());
323
324         $discoverRequest = $transactions->requestAt(0);
325         $keysRequest = $transactions->requestAt(1);
326         $this->assertEquals('GET', $keysRequest->getMethod());
327         $this->assertEquals('GET', $discoverRequest->getMethod());
328         $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri());
329         $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/oidc/keys', $keysRequest->getUri());
330     }
331
332     public function test_auth_fails_if_autodiscovery_fails()
333     {
334         $this->withAutodiscovery();
335         $this->mockHttpClient([
336             new Response(404, [], 'Not found'),
337         ]);
338
339         $resp = $this->followRedirects($this->runLogin());
340         $this->assertFalse(auth()->check());
341         $resp->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
342     }
343
344     public function test_autodiscovery_calls_are_cached()
345     {
346         $this->withAutodiscovery();
347
348         $transactions = $this->mockHttpClient([
349             $this->getAutoDiscoveryResponse(),
350             $this->getJwksResponse(),
351             $this->getAutoDiscoveryResponse([
352                 'issuer' => 'https://auto.example.com',
353             ]),
354             $this->getJwksResponse(),
355         ]);
356
357         // Initial run
358         $this->post('/oidc/login');
359         $this->assertEquals(2, $transactions->requestCount());
360         // Second run, hits cache
361         $this->post('/oidc/login');
362         $this->assertEquals(2, $transactions->requestCount());
363
364         // Third run, different issuer, new cache key
365         config()->set(['oidc.issuer' => 'https://auto.example.com']);
366         $this->post('/oidc/login');
367         $this->assertEquals(4, $transactions->requestCount());
368     }
369
370     public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_alg_property()
371     {
372         $this->withAutodiscovery();
373
374         $keyArray = OidcJwtHelper::publicJwkKeyArray();
375         unset($keyArray['alg']);
376
377         $this->mockHttpClient([
378             $this->getAutoDiscoveryResponse(),
379             new Response(200, [
380                 'Content-Type'  => 'application/json',
381                 'Cache-Control' => 'no-cache, no-store',
382                 'Pragma'        => 'no-cache',
383             ], json_encode([
384                 'keys' => [
385                     $keyArray,
386                 ],
387             ])),
388         ]);
389
390         $this->assertFalse(auth()->check());
391         $this->runLogin();
392         $this->assertTrue(auth()->check());
393     }
394
395     public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_use_property()
396     {
397         // Based on reading the OIDC discovery spec:
398         // > This contains the signing key(s) the RP uses to validate signatures from the OP. The JWK Set MAY also
399         // > contain the Server's encryption key(s), which are used by RPs to encrypt requests to the Server. When
400         // > both signing and encryption keys are made available, a use (Key Use) parameter value is REQUIRED for all
401         // > keys in the referenced JWK Set to indicate each key's intended usage.
402         // We can assume that keys without use are intended for signing.
403         $this->withAutodiscovery();
404
405         $keyArray = OidcJwtHelper::publicJwkKeyArray();
406         unset($keyArray['use']);
407
408         $this->mockHttpClient([
409             $this->getAutoDiscoveryResponse(),
410             new Response(200, [
411                 'Content-Type'  => 'application/json',
412                 'Cache-Control' => 'no-cache, no-store',
413                 'Pragma'        => 'no-cache',
414             ], json_encode([
415                 'keys' => [
416                     $keyArray,
417                 ],
418             ])),
419         ]);
420
421         $this->assertFalse(auth()->check());
422         $this->runLogin();
423         $this->assertTrue(auth()->check());
424     }
425
426     public function test_auth_uses_configured_external_id_claim_option()
427     {
428         config()->set([
429             'oidc.external_id_claim' => 'super_awesome_id',
430         ]);
431
432         $resp = $this->runLogin([
433             'email'            => '[email protected]',
434             'sub'              => 'benny1010101',
435             'super_awesome_id' => 'xXBennyTheGeezXx',
436         ]);
437         $resp->assertRedirect('/');
438
439         /** @var User $user */
440         $user = User::query()->where('email', '=', '[email protected]')->first();
441         $this->assertEquals('xXBennyTheGeezXx', $user->external_auth_id);
442     }
443
444     public function test_auth_uses_mulitple_display_name_claims_if_configured()
445     {
446         config()->set(['oidc.display_name_claims' => 'first_name|last_name']);
447
448         $this->runLogin([
449             'email'      => '[email protected]',
450             'sub'        => 'benny1010101',
451             'first_name' => 'Benny',
452             'last_name'  => 'Jenkins'
453         ]);
454
455         $this->assertDatabaseHas('users', [
456             'name' => 'Benny Jenkins',
457             'email' => '[email protected]',
458         ]);
459     }
460
461     public function test_user_avatar_fetched_from_picture_on_first_login_if_enabled()
462     {
463         config()->set(['oidc.fetch_avatar' => true]);
464
465         $this->runLogin([
466             'email' => '[email protected]',
467             'picture' => 'https://example.com/my-avatar.jpg',
468         ], [
469             new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData())
470         ]);
471
472         $user = User::query()->where('email', '=', '[email protected]')->first();
473         $this->assertNotNull($user);
474
475         $this->assertTrue($user->avatar()->exists());
476     }
477
478     public function test_user_avatar_not_fetched_if_image_data_format_unknown()
479     {
480         config()->set(['oidc.fetch_avatar' => true]);
481
482         $this->runLogin([
483             'email' => '[email protected]',
484             'picture' => 'https://example.com/my-avatar.jpg',
485         ], [
486             new Response(200, ['Content-Type' => 'image/jpeg'], str_repeat('abc123', 5))
487         ]);
488
489         $user = User::query()->where('email', '=', '[email protected]')->first();
490         $this->assertNotNull($user);
491
492         $this->assertFalse($user->avatar()->exists());
493     }
494
495     public function test_user_avatar_not_fetched_when_user_already_exists()
496     {
497         config()->set(['oidc.fetch_avatar' => true]);
498         $editor = $this->users->editor();
499         $editor->external_auth_id = 'benny509';
500
501         $this->runLogin([
502             'picture' => 'https://example.com/my-avatar.jpg',
503             'sub' => 'benny509',
504         ], [
505             new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData())
506         ]);
507
508         $editor->refresh();
509         $this->assertFalse($editor->avatar()->exists());
510     }
511
512     public function test_login_group_sync()
513     {
514         config()->set([
515             'oidc.user_to_groups'     => true,
516             'oidc.groups_claim'       => 'groups',
517             'oidc.remove_from_groups' => false,
518         ]);
519         $roleA = Role::factory()->create(['display_name' => 'Wizards']);
520         $roleB = Role::factory()->create(['display_name' => 'ZooFolks', 'external_auth_id' => 'zookeepers']);
521         $roleC = Role::factory()->create(['display_name' => 'Another Role']);
522
523         $resp = $this->runLogin([
524             'email'  => '[email protected]',
525             'sub'    => 'benny1010101',
526             'groups' => ['Wizards', 'Zookeepers'],
527         ]);
528         $resp->assertRedirect('/');
529
530         /** @var User $user */
531         $user = User::query()->where('email', '=', '[email protected]')->first();
532
533         $this->assertTrue($user->hasRole($roleA->id));
534         $this->assertTrue($user->hasRole($roleB->id));
535         $this->assertFalse($user->hasRole($roleC->id));
536     }
537
538     public function test_login_group_sync_with_nested_groups_in_token()
539     {
540         config()->set([
541             'oidc.user_to_groups'     => true,
542             'oidc.groups_claim'       => 'my.custom.groups.attr',
543             'oidc.remove_from_groups' => false,
544         ]);
545         $roleA = Role::factory()->create(['display_name' => 'Wizards']);
546
547         $resp = $this->runLogin([
548             'email'  => '[email protected]',
549             'sub'    => 'benny1010101',
550             'my'     => [
551                 'custom' => [
552                     'groups' => [
553                         'attr' => ['Wizards'],
554                     ],
555                 ],
556             ],
557         ]);
558         $resp->assertRedirect('/');
559
560         /** @var User $user */
561         $user = User::query()->where('email', '=', '[email protected]')->first();
562         $this->assertTrue($user->hasRole($roleA->id));
563     }
564
565     public function test_oidc_logout_form_active_when_oidc_active()
566     {
567         $this->runLogin();
568
569         $resp = $this->get('/');
570         $this->withHtml($resp)->assertElementExists('header form[action$="/oidc/logout"] button');
571     }
572     public function test_logout_with_autodiscovery_with_oidc_logout_enabled()
573     {
574         config()->set(['oidc.end_session_endpoint' => true]);
575         $this->withAutodiscovery();
576
577         $transactions = $this->mockHttpClient([
578             $this->getAutoDiscoveryResponse(),
579             $this->getJwksResponse(),
580         ]);
581
582         $resp = $this->asEditor()->post('/oidc/logout');
583         $resp->assertRedirect('https://auth.example.com/oidc/logout?post_logout_redirect_uri=' . urlencode(url('/')));
584
585         $this->assertEquals(2, $transactions->requestCount());
586         $this->assertFalse(auth()->check());
587     }
588
589     public function test_logout_with_autodiscovery_with_oidc_logout_disabled()
590     {
591         $this->withAutodiscovery();
592         config()->set(['oidc.end_session_endpoint' => false]);
593
594         $this->mockHttpClient([
595             $this->getAutoDiscoveryResponse(),
596             $this->getJwksResponse(),
597         ]);
598
599         $resp = $this->asEditor()->post('/oidc/logout');
600         $resp->assertRedirect('/');
601         $this->assertFalse(auth()->check());
602     }
603
604     public function test_logout_without_autodiscovery_but_with_endpoint_configured()
605     {
606         config()->set(['oidc.end_session_endpoint' => 'https://example.com/logout']);
607
608         $resp = $this->asEditor()->post('/oidc/logout');
609         $resp->assertRedirect('https://example.com/logout?post_logout_redirect_uri=' . urlencode(url('/')));
610         $this->assertFalse(auth()->check());
611     }
612
613     public function test_logout_without_autodiscovery_with_configured_endpoint_adds_to_query_if_existing()
614     {
615         config()->set(['oidc.end_session_endpoint' => 'https://example.com/logout?a=b']);
616
617         $resp = $this->asEditor()->post('/oidc/logout');
618         $resp->assertRedirect('https://example.com/logout?a=b&post_logout_redirect_uri=' . urlencode(url('/')));
619         $this->assertFalse(auth()->check());
620     }
621
622     public function test_logout_with_autodiscovery_and_auto_initiate_returns_to_auto_prevented_login()
623     {
624         $this->withAutodiscovery();
625         config()->set([
626             'auth.auto_initiate' => true,
627             'services.google.client_id' => false,
628             'services.github.client_id' => false,
629             'oidc.end_session_endpoint' => true,
630         ]);
631
632         $this->mockHttpClient([
633             $this->getAutoDiscoveryResponse(),
634             $this->getJwksResponse(),
635         ]);
636
637         $resp = $this->asEditor()->post('/oidc/logout');
638
639         $redirectUrl = url('/login?prevent_auto_init=true');
640         $resp->assertRedirect('https://auth.example.com/oidc/logout?post_logout_redirect_uri=' . urlencode($redirectUrl));
641         $this->assertFalse(auth()->check());
642     }
643
644     public function test_logout_endpoint_url_overrides_autodiscovery_endpoint()
645     {
646         config()->set(['oidc.end_session_endpoint' => 'https://a.example.com']);
647         $this->withAutodiscovery();
648
649         $transactions = $this->mockHttpClient([
650             $this->getAutoDiscoveryResponse(),
651             $this->getJwksResponse(),
652         ]);
653
654         $resp = $this->asEditor()->post('/oidc/logout');
655         $resp->assertRedirect('https://a.example.com?post_logout_redirect_uri=' . urlencode(url('/')));
656
657         $this->assertEquals(2, $transactions->requestCount());
658         $this->assertFalse(auth()->check());
659     }
660
661     public function test_logout_with_autodiscovery_does_not_use_rp_logout_if_no_url_via_autodiscovery()
662     {
663         config()->set(['oidc.end_session_endpoint' => true]);
664         $this->withAutodiscovery();
665
666         $this->mockHttpClient([
667             $this->getAutoDiscoveryResponse(['end_session_endpoint' => null]),
668             $this->getJwksResponse(),
669         ]);
670
671         $resp = $this->asEditor()->post('/oidc/logout');
672         $resp->assertRedirect('/');
673         $this->assertFalse(auth()->check());
674     }
675
676     public function test_logout_redirect_contains_id_token_hint_if_existing()
677     {
678         config()->set(['oidc.end_session_endpoint' => 'https://example.com/logout']);
679
680         // Fix times so our token is predictable
681         $claimOverrides = [
682             'iat' => time(),
683             'exp' => time() + 720,
684             'auth_time' => time()
685         ];
686         $this->runLogin($claimOverrides);
687
688         $resp = $this->asEditor()->post('/oidc/logout');
689         $query = 'id_token_hint=' . urlencode(OidcJwtHelper::idToken($claimOverrides)) .  '&post_logout_redirect_uri=' . urlencode(url('/'));
690         $resp->assertRedirect('https://example.com/logout?' . $query);
691     }
692
693     public function test_oidc_id_token_pre_validate_theme_event_without_return()
694     {
695         $args = [];
696         $callback = function (...$eventArgs) use (&$args) {
697             $args = $eventArgs;
698         };
699         Theme::listen(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $callback);
700
701         $resp = $this->runLogin([
702             'email' => '[email protected]',
703             'sub'   => 'benny1010101',
704             'name'  => 'Benny',
705         ]);
706         $resp->assertRedirect('/');
707
708         $this->assertDatabaseHas('users', [
709             'external_auth_id' => 'benny1010101',
710         ]);
711
712         $this->assertArrayHasKey('iss', $args[0]);
713         $this->assertArrayHasKey('sub', $args[0]);
714         $this->assertEquals('Benny', $args[0]['name']);
715         $this->assertEquals('benny1010101', $args[0]['sub']);
716
717         $this->assertArrayHasKey('access_token', $args[1]);
718         $this->assertArrayHasKey('expires_in', $args[1]);
719         $this->assertArrayHasKey('refresh_token', $args[1]);
720     }
721
722     public function test_oidc_id_token_pre_validate_theme_event_with_return()
723     {
724         $callback = function (...$eventArgs) {
725             return array_merge($eventArgs[0], [
726                 'email' => '[email protected]',
727                 'sub' => 'lenny1010101',
728                 'name' => 'Lenny',
729             ]);
730         };
731         Theme::listen(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $callback);
732
733         $resp = $this->runLogin([
734             'email' => '[email protected]',
735             'sub'   => 'benny1010101',
736             'name'  => 'Benny',
737         ]);
738         $resp->assertRedirect('/');
739
740         $this->assertDatabaseHas('users', [
741             'email' => '[email protected]',
742             'external_auth_id' => 'lenny1010101',
743             'name' => 'Lenny',
744         ]);
745     }
746
747     public function test_pkce_used_on_authorize_and_access()
748     {
749         // Start auth
750         $resp = $this->post('/oidc/login');
751         $state = session()->get('oidc_state');
752
753         $pkceCode = session()->get('oidc_pkce_code');
754         $this->assertGreaterThan(30, strlen($pkceCode));
755
756         $expectedCodeChallenge = trim(strtr(base64_encode(hash('sha256', $pkceCode, true)), '+/', '-_'), '=');
757         $redirect = $resp->headers->get('Location');
758         $redirectParams = [];
759         parse_str(parse_url($redirect, PHP_URL_QUERY), $redirectParams);
760         $this->assertEquals($expectedCodeChallenge, $redirectParams['code_challenge']);
761         $this->assertEquals('S256', $redirectParams['code_challenge_method']);
762
763         $transactions = $this->mockHttpClient([$this->getMockAuthorizationResponse([
764             'email' => '[email protected]',
765             'sub'   => 'benny1010101',
766         ])]);
767
768         $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
769         $tokenRequest = $transactions->latestRequest();
770         $bodyParams = [];
771         parse_str($tokenRequest->getBody(), $bodyParams);
772         $this->assertEquals($pkceCode, $bodyParams['code_verifier']);
773     }
774
775     public function test_userinfo_endpoint_used_if_missing_claims_in_id_token()
776     {
777         config()->set('oidc.display_name_claims', 'first_name|last_name');
778         $this->post('/oidc/login');
779         $state = session()->get('oidc_state');
780
781         $client = $this->mockHttpClient([
782             $this->getMockAuthorizationResponse(['name' => null]),
783             new Response(200, [
784                 'Content-Type'  => 'application/json',
785             ], json_encode([
786                 'sub' => OidcJwtHelper::defaultPayload()['sub'],
787                 'first_name' => 'Barry',
788                 'last_name' => 'Userinfo',
789             ]))
790         ]);
791
792         $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
793         $resp->assertRedirect('/');
794         $this->assertEquals(2, $client->requestCount());
795
796         $userinfoRequest = $client->requestAt(1);
797         $this->assertEquals('GET', $userinfoRequest->getMethod());
798         $this->assertEquals('https://oidc.local/userinfo', (string) $userinfoRequest->getUri());
799
800         $this->assertEquals('Barry Userinfo', user()->name);
801     }
802
803     public function test_userinfo_endpoint_fetch_with_different_sub_throws_error()
804     {
805         $userinfoResponseData = ['sub' => 'dcba4321'];
806         $userinfoResponse = new Response(200, ['Content-Type'  => 'application/json'], json_encode($userinfoResponseData));
807         $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
808         $resp->assertRedirect('/login');
809         $this->assertSessionError('Userinfo endpoint response validation failed with error: Subject value provided in the userinfo endpoint does not match the provided ID token value');
810     }
811
812     public function test_userinfo_endpoint_fetch_returning_no_sub_throws_error()
813     {
814         $userinfoResponseData = ['name' => 'testing'];
815         $userinfoResponse = new Response(200, ['Content-Type'  => 'application/json'], json_encode($userinfoResponseData));
816         $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
817         $resp->assertRedirect('/login');
818         $this->assertSessionError('Userinfo endpoint response validation failed with error: No valid subject value found in userinfo data');
819     }
820
821     public function test_userinfo_endpoint_fetch_can_parsed_nested_groups()
822     {
823         config()->set([
824             'oidc.user_to_groups'     => true,
825             'oidc.groups_claim'       => 'my.nested.groups.attr',
826             'oidc.remove_from_groups' => false,
827         ]);
828
829         $roleA = Role::factory()->create(['display_name' => 'Ducks']);
830         $userinfoResponseData = [
831             'sub' => OidcJwtHelper::defaultPayload()['sub'],
832             'my' => ['nested' => ['groups' => ['attr' => ['Ducks', 'Donkeys']]]]
833         ];
834         $userinfoResponse = new Response(200, ['Content-Type'  => 'application/json'], json_encode($userinfoResponseData));
835         $resp = $this->runLogin(['groups' => null], [$userinfoResponse]);
836         $resp->assertRedirect('/');
837
838         $user = User::where('email', OidcJwtHelper::defaultPayload()['email'])->first();
839         $this->assertTrue($user->hasRole($roleA->id));
840     }
841
842     public function test_userinfo_endpoint_response_with_complex_json_content_type_handled()
843     {
844         $userinfoResponseData = [
845             'sub' => OidcJwtHelper::defaultPayload()['sub'],
846             'name' => 'Barry',
847         ];
848         $userinfoResponse = new Response(200, ['Content-Type'  => 'Application/Json ; charset=utf-8'], json_encode($userinfoResponseData));
849         $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
850         $resp->assertRedirect('/');
851
852         $user = User::where('email', OidcJwtHelper::defaultPayload()['email'])->first();
853         $this->assertEquals('Barry', $user->name);
854     }
855
856     public function test_userinfo_endpoint_jwks_response_handled()
857     {
858         $userinfoResponseData = OidcJwtHelper::idToken(['name' => 'Barry Jwks']);
859         $userinfoResponse = new Response(200, ['Content-Type'  => 'application/jwt'], $userinfoResponseData);
860
861         $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
862         $resp->assertRedirect('/');
863
864         $user = User::where('email', OidcJwtHelper::defaultPayload()['email'])->first();
865         $this->assertEquals('Barry Jwks', $user->name);
866     }
867
868     public function test_userinfo_endpoint_jwks_response_returning_no_sub_throws()
869     {
870         $userinfoResponseData = OidcJwtHelper::idToken(['sub' => null]);
871         $userinfoResponse = new Response(200, ['Content-Type'  => 'application/jwt'], $userinfoResponseData);
872
873         $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
874         $resp->assertRedirect('/login');
875         $this->assertSessionError('Userinfo endpoint response validation failed with error: No valid subject value found in userinfo data');
876     }
877
878     public function test_userinfo_endpoint_jwks_response_returning_non_matching_sub_throws()
879     {
880         $userinfoResponseData = OidcJwtHelper::idToken(['sub' => 'zzz123']);
881         $userinfoResponse = new Response(200, ['Content-Type'  => 'application/jwt'], $userinfoResponseData);
882
883         $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
884         $resp->assertRedirect('/login');
885         $this->assertSessionError('Userinfo endpoint response validation failed with error: Subject value provided in the userinfo endpoint does not match the provided ID token value');
886     }
887
888     public function test_userinfo_endpoint_jwks_response_with_invalid_signature_throws()
889     {
890         $userinfoResponseData = OidcJwtHelper::idToken();
891         $exploded = explode('.', $userinfoResponseData);
892         $exploded[2] = base64_encode(base64_decode($exploded[2]) . 'ABC');
893         $userinfoResponse = new Response(200, ['Content-Type'  => 'application/jwt'], implode('.', $exploded));
894
895         $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
896         $resp->assertRedirect('/login');
897         $this->assertSessionError('Userinfo endpoint response validation failed with error: Token signature could not be validated using the provided keys');
898     }
899
900     public function test_userinfo_endpoint_jwks_response_with_invalid_signature_alg_throws()
901     {
902         $userinfoResponseData = OidcJwtHelper::idToken([], ['alg' => 'ZZ512']);
903         $userinfoResponse = new Response(200, ['Content-Type'  => 'application/jwt'], $userinfoResponseData);
904
905         $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
906         $resp->assertRedirect('/login');
907         $this->assertSessionError('Userinfo endpoint response validation failed with error: Only RS256 signature validation is supported. Token reports using ZZ512');
908     }
909
910     public function test_userinfo_endpoint_response_with_invalid_content_type_throws()
911     {
912         $userinfoResponse = new Response(200, ['Content-Type'  => 'application/beans'], json_encode(OidcJwtHelper::defaultPayload()));
913         $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
914         $resp->assertRedirect('/login');
915         $this->assertSessionError('Userinfo endpoint response validation failed with error: No valid subject value found in userinfo data');
916     }
917
918     public function test_userinfo_endpoint_not_called_if_empty_groups_array_provided_in_id_token()
919     {
920         config()->set([
921             'oidc.user_to_groups'     => true,
922             'oidc.groups_claim'       => 'groups',
923             'oidc.remove_from_groups' => false,
924         ]);
925
926         $this->post('/oidc/login');
927         $state = session()->get('oidc_state');
928         $client = $this->mockHttpClient([$this->getMockAuthorizationResponse([
929             'groups' => [],
930         ])]);
931
932         $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
933         $resp->assertRedirect('/');
934         $this->assertEquals(1, $client->requestCount());
935         $this->assertTrue(auth()->check());
936     }
937
938     protected function withAutodiscovery(): void
939     {
940         config()->set([
941             'oidc.issuer'                 => OidcJwtHelper::defaultIssuer(),
942             'oidc.discover'               => true,
943             'oidc.authorization_endpoint' => null,
944             'oidc.token_endpoint'         => null,
945             'oidc.userinfo_endpoint'      => null,
946             'oidc.jwt_public_key'         => null,
947         ]);
948     }
949
950     protected function runLogin($claimOverrides = [], $additionalHttpResponses = []): TestResponse
951     {
952         $this->post('/oidc/login');
953         $state = session()->get('oidc_state');
954         $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides), ...$additionalHttpResponses]);
955
956         return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
957     }
958
959     protected function getAutoDiscoveryResponse($responseOverrides = []): Response
960     {
961         return new Response(200, [
962             'Content-Type'  => 'application/json',
963             'Cache-Control' => 'no-cache, no-store',
964             'Pragma'        => 'no-cache',
965         ], json_encode(array_merge([
966             'token_endpoint'         => OidcJwtHelper::defaultIssuer() . '/oidc/token',
967             'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize',
968             'userinfo_endpoint'      => OidcJwtHelper::defaultIssuer() . '/oidc/userinfo',
969             'jwks_uri'               => OidcJwtHelper::defaultIssuer() . '/oidc/keys',
970             'issuer'                 => OidcJwtHelper::defaultIssuer(),
971             'end_session_endpoint'   => OidcJwtHelper::defaultIssuer() . '/oidc/logout',
972         ], $responseOverrides)));
973     }
974
975     protected function getJwksResponse(): Response
976     {
977         return new Response(200, [
978             'Content-Type'  => 'application/json',
979             'Cache-Control' => 'no-cache, no-store',
980             'Pragma'        => 'no-cache',
981         ], json_encode([
982             'keys' => [
983                 OidcJwtHelper::publicJwkKeyArray(),
984             ],
985         ]));
986     }
987
988     protected function getMockAuthorizationResponse($claimOverrides = []): Response
989     {
990         return new Response(200, [
991             'Content-Type'  => 'application/json',
992             'Cache-Control' => 'no-cache, no-store',
993             'Pragma'        => 'no-cache',
994         ], json_encode([
995             'access_token' => 'abc123',
996             'token_type'   => 'Bearer',
997             'expires_in'   => 3600,
998             'id_token'     => OidcJwtHelper::idToken($claimOverrides),
999         ]));
1000     }
1001 }