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