]> BookStack Code Mirror - bookstack/blob - tests/Auth/OidcTest.php
OIDC: Added testing of PKCE flow
[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             'oidc.end_session_endpoint'   => false,
48         ]);
49     }
50
51     protected function tearDown(): void
52     {
53         parent::tearDown();
54         if (file_exists($this->keyFilePath)) {
55             unlink($this->keyFilePath);
56         }
57     }
58
59     public function test_login_option_shows_on_login_page()
60     {
61         $req = $this->get('/login');
62         $req->assertSeeText('SingleSignOn-Testing');
63         $this->withHtml($req)->assertElementExists('form[action$="/oidc/login"][method=POST] button');
64     }
65
66     public function test_oidc_routes_are_only_active_if_oidc_enabled()
67     {
68         config()->set(['auth.method' => 'standard']);
69         $routes = ['/login' => 'post', '/callback' => 'get'];
70         foreach ($routes as $uri => $method) {
71             $req = $this->call($method, '/oidc' . $uri);
72             $this->assertPermissionError($req);
73         }
74     }
75
76     public function test_forgot_password_routes_inaccessible()
77     {
78         $resp = $this->get('/password/email');
79         $this->assertPermissionError($resp);
80
81         $resp = $this->post('/password/email');
82         $this->assertPermissionError($resp);
83
84         $resp = $this->get('/password/reset/abc123');
85         $this->assertPermissionError($resp);
86
87         $resp = $this->post('/password/reset');
88         $this->assertPermissionError($resp);
89     }
90
91     public function test_standard_login_routes_inaccessible()
92     {
93         $resp = $this->post('/login');
94         $this->assertPermissionError($resp);
95     }
96
97     public function test_logout_route_functions()
98     {
99         $this->actingAs($this->users->editor());
100         $this->post('/logout');
101         $this->assertFalse(auth()->check());
102     }
103
104     public function test_user_invite_routes_inaccessible()
105     {
106         $resp = $this->get('/register/invite/abc123');
107         $this->assertPermissionError($resp);
108
109         $resp = $this->post('/register/invite/abc123');
110         $this->assertPermissionError($resp);
111     }
112
113     public function test_user_register_routes_inaccessible()
114     {
115         $resp = $this->get('/register');
116         $this->assertPermissionError($resp);
117
118         $resp = $this->post('/register');
119         $this->assertPermissionError($resp);
120     }
121
122     public function test_login()
123     {
124         $req = $this->post('/oidc/login');
125         $redirect = $req->headers->get('location');
126
127         $this->assertStringStartsWith('https://oidc.local/auth', $redirect, 'Login redirects to SSO location');
128         $this->assertFalse($this->isAuthenticated());
129         $this->assertStringContainsString('scope=openid%20profile%20email', $redirect);
130         $this->assertStringContainsString('client_id=' . OidcJwtHelper::defaultClientId(), $redirect);
131         $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $redirect);
132     }
133
134     public function test_login_success_flow()
135     {
136         // Start auth
137         $this->post('/oidc/login');
138         $state = session()->get('oidc_state');
139
140         $transactions = $this->mockHttpClient([$this->getMockAuthorizationResponse([
141             'email' => '[email protected]',
142             'sub'   => 'benny1010101',
143         ])]);
144
145         // Callback from auth provider
146         // App calls token endpoint to get id token
147         $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
148         $resp->assertRedirect('/');
149         $this->assertEquals(1, $transactions->requestCount());
150         $tokenRequest = $transactions->latestRequest();
151         $this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri());
152         $this->assertEquals('POST', $tokenRequest->getMethod());
153         $this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]);
154         $this->assertStringContainsString('grant_type=authorization_code', $tokenRequest->getBody());
155         $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody());
156         $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody());
157
158         $this->assertTrue(auth()->check());
159         $this->assertDatabaseHas('users', [
160             'email'            => '[email protected]',
161             'external_auth_id' => 'benny1010101',
162             'email_confirmed'  => false,
163         ]);
164
165         $user = User::query()->where('email', '=', '[email protected]')->first();
166         $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott");
167     }
168
169     public function test_login_uses_custom_additional_scopes_if_defined()
170     {
171         config()->set([
172             'oidc.additional_scopes' => 'groups, badgers',
173         ]);
174
175         $redirect = $this->post('/oidc/login')->headers->get('location');
176
177         $this->assertStringContainsString('scope=openid%20profile%20email%20groups%20badgers', $redirect);
178     }
179
180     public function test_callback_fails_if_no_state_present_or_matching()
181     {
182         $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
183         $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
184
185         $this->post('/oidc/login');
186         $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
187         $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
188     }
189
190     public function test_dump_user_details_option_outputs_as_expected()
191     {
192         config()->set('oidc.dump_user_details', true);
193
194         $resp = $this->runLogin([
195             'email' => '[email protected]',
196             'sub'   => 'benny505',
197         ]);
198
199         $resp->assertStatus(200);
200         $resp->assertJson([
201             'email' => '[email protected]',
202             'sub'   => 'benny505',
203             'iss'   => OidcJwtHelper::defaultIssuer(),
204             'aud'   => OidcJwtHelper::defaultClientId(),
205         ]);
206         $this->assertFalse(auth()->check());
207     }
208
209     public function test_auth_fails_if_no_email_exists_in_user_data()
210     {
211         $this->runLogin([
212             'email' => '',
213             'sub'   => 'benny505',
214         ]);
215
216         $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system');
217     }
218
219     public function test_auth_fails_if_already_logged_in()
220     {
221         $this->asEditor();
222
223         $this->runLogin([
224             'email' => '[email protected]',
225             'sub'   => 'benny505',
226         ]);
227
228         $this->assertSessionError('Already logged in');
229     }
230
231     public function test_auth_login_as_existing_user()
232     {
233         $editor = $this->users->editor();
234         $editor->external_auth_id = 'benny505';
235         $editor->save();
236
237         $this->assertFalse(auth()->check());
238
239         $this->runLogin([
240             'email' => '[email protected]',
241             'sub'   => 'benny505',
242         ]);
243
244         $this->assertTrue(auth()->check());
245         $this->assertEquals($editor->id, auth()->user()->id);
246     }
247
248     public function test_auth_login_as_existing_user_email_with_different_auth_id_fails()
249     {
250         $editor = $this->users->editor();
251         $editor->external_auth_id = 'editor101';
252         $editor->save();
253
254         $this->assertFalse(auth()->check());
255
256         $resp = $this->runLogin([
257             'email' => $editor->email,
258             'sub'   => 'benny505',
259         ]);
260         $resp = $this->followRedirects($resp);
261
262         $resp->assertSeeText('A user with the email ' . $editor->email . ' already exists but with different credentials.');
263         $this->assertFalse(auth()->check());
264     }
265
266     public function test_auth_login_with_invalid_token_fails()
267     {
268         $resp = $this->runLogin([
269             'sub' => null,
270         ]);
271         $resp = $this->followRedirects($resp);
272
273         $resp->assertSeeText('ID token validate failed with error: Missing token subject value');
274         $this->assertFalse(auth()->check());
275     }
276
277     public function test_auth_login_with_autodiscovery()
278     {
279         $this->withAutodiscovery();
280
281         $transactions = $this->mockHttpClient([
282             $this->getAutoDiscoveryResponse(),
283             $this->getJwksResponse(),
284         ]);
285
286         $this->assertFalse(auth()->check());
287
288         $this->runLogin();
289
290         $this->assertTrue(auth()->check());
291
292         $discoverRequest = $transactions->requestAt(0);
293         $keysRequest = $transactions->requestAt(1);
294         $this->assertEquals('GET', $keysRequest->getMethod());
295         $this->assertEquals('GET', $discoverRequest->getMethod());
296         $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri());
297         $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/oidc/keys', $keysRequest->getUri());
298     }
299
300     public function test_auth_fails_if_autodiscovery_fails()
301     {
302         $this->withAutodiscovery();
303         $this->mockHttpClient([
304             new Response(404, [], 'Not found'),
305         ]);
306
307         $resp = $this->followRedirects($this->runLogin());
308         $this->assertFalse(auth()->check());
309         $resp->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
310     }
311
312     public function test_autodiscovery_calls_are_cached()
313     {
314         $this->withAutodiscovery();
315
316         $transactions = $this->mockHttpClient([
317             $this->getAutoDiscoveryResponse(),
318             $this->getJwksResponse(),
319             $this->getAutoDiscoveryResponse([
320                 'issuer' => 'https://auto.example.com',
321             ]),
322             $this->getJwksResponse(),
323         ]);
324
325         // Initial run
326         $this->post('/oidc/login');
327         $this->assertEquals(2, $transactions->requestCount());
328         // Second run, hits cache
329         $this->post('/oidc/login');
330         $this->assertEquals(2, $transactions->requestCount());
331
332         // Third run, different issuer, new cache key
333         config()->set(['oidc.issuer' => 'https://auto.example.com']);
334         $this->post('/oidc/login');
335         $this->assertEquals(4, $transactions->requestCount());
336     }
337
338     public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_alg_property()
339     {
340         $this->withAutodiscovery();
341
342         $keyArray = OidcJwtHelper::publicJwkKeyArray();
343         unset($keyArray['alg']);
344
345         $this->mockHttpClient([
346             $this->getAutoDiscoveryResponse(),
347             new Response(200, [
348                 'Content-Type'  => 'application/json',
349                 'Cache-Control' => 'no-cache, no-store',
350                 'Pragma'        => 'no-cache',
351             ], json_encode([
352                 'keys' => [
353                     $keyArray,
354                 ],
355             ])),
356         ]);
357
358         $this->assertFalse(auth()->check());
359         $this->runLogin();
360         $this->assertTrue(auth()->check());
361     }
362
363     public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_use_property()
364     {
365         // Based on reading the OIDC discovery spec:
366         // > This contains the signing key(s) the RP uses to validate signatures from the OP. The JWK Set MAY also
367         // > contain the Server's encryption key(s), which are used by RPs to encrypt requests to the Server. When
368         // > both signing and encryption keys are made available, a use (Key Use) parameter value is REQUIRED for all
369         // > keys in the referenced JWK Set to indicate each key's intended usage.
370         // We can assume that keys without use are intended for signing.
371         $this->withAutodiscovery();
372
373         $keyArray = OidcJwtHelper::publicJwkKeyArray();
374         unset($keyArray['use']);
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_uses_configured_external_id_claim_option()
395     {
396         config()->set([
397             'oidc.external_id_claim' => 'super_awesome_id',
398         ]);
399
400         $resp = $this->runLogin([
401             'email'            => '[email protected]',
402             'sub'              => 'benny1010101',
403             'super_awesome_id' => 'xXBennyTheGeezXx',
404         ]);
405         $resp->assertRedirect('/');
406
407         /** @var User $user */
408         $user = User::query()->where('email', '=', '[email protected]')->first();
409         $this->assertEquals('xXBennyTheGeezXx', $user->external_auth_id);
410     }
411
412     public function test_auth_uses_mulitple_display_name_claims_if_configured()
413     {
414         config()->set(['oidc.display_name_claims' => 'first_name|last_name']);
415
416         $this->runLogin([
417             'email'      => '[email protected]',
418             'sub'        => 'benny1010101',
419             'first_name' => 'Benny',
420             'last_name'  => 'Jenkins'
421         ]);
422
423         $this->assertDatabaseHas('users', [
424             'name' => 'Benny Jenkins',
425             'email' => '[email protected]',
426         ]);
427     }
428
429     public function test_login_group_sync()
430     {
431         config()->set([
432             'oidc.user_to_groups'     => true,
433             'oidc.groups_claim'       => 'groups',
434             'oidc.remove_from_groups' => false,
435         ]);
436         $roleA = Role::factory()->create(['display_name' => 'Wizards']);
437         $roleB = Role::factory()->create(['display_name' => 'ZooFolks', 'external_auth_id' => 'zookeepers']);
438         $roleC = Role::factory()->create(['display_name' => 'Another Role']);
439
440         $resp = $this->runLogin([
441             'email'  => '[email protected]',
442             'sub'    => 'benny1010101',
443             'groups' => ['Wizards', 'Zookeepers'],
444         ]);
445         $resp->assertRedirect('/');
446
447         /** @var User $user */
448         $user = User::query()->where('email', '=', '[email protected]')->first();
449
450         $this->assertTrue($user->hasRole($roleA->id));
451         $this->assertTrue($user->hasRole($roleB->id));
452         $this->assertFalse($user->hasRole($roleC->id));
453     }
454
455     public function test_login_group_sync_with_nested_groups_in_token()
456     {
457         config()->set([
458             'oidc.user_to_groups'     => true,
459             'oidc.groups_claim'       => 'my.custom.groups.attr',
460             'oidc.remove_from_groups' => false,
461         ]);
462         $roleA = Role::factory()->create(['display_name' => 'Wizards']);
463
464         $resp = $this->runLogin([
465             'email'  => '[email protected]',
466             'sub'    => 'benny1010101',
467             'my'     => [
468                 'custom' => [
469                     'groups' => [
470                         'attr' => ['Wizards'],
471                     ],
472                 ],
473             ],
474         ]);
475         $resp->assertRedirect('/');
476
477         /** @var User $user */
478         $user = User::query()->where('email', '=', '[email protected]')->first();
479         $this->assertTrue($user->hasRole($roleA->id));
480     }
481
482     public function test_oidc_logout_form_active_when_oidc_active()
483     {
484         $this->runLogin();
485
486         $resp = $this->get('/');
487         $this->withHtml($resp)->assertElementExists('header form[action$="/oidc/logout"] button');
488     }
489     public function test_logout_with_autodiscovery_with_oidc_logout_enabled()
490     {
491         config()->set(['oidc.end_session_endpoint' => true]);
492         $this->withAutodiscovery();
493
494         $transactions = $this->mockHttpClient([
495             $this->getAutoDiscoveryResponse(),
496             $this->getJwksResponse(),
497         ]);
498
499         $resp = $this->asEditor()->post('/oidc/logout');
500         $resp->assertRedirect('https://auth.example.com/oidc/logout?post_logout_redirect_uri=' . urlencode(url('/')));
501
502         $this->assertEquals(2, $transactions->requestCount());
503         $this->assertFalse(auth()->check());
504     }
505
506     public function test_logout_with_autodiscovery_with_oidc_logout_disabled()
507     {
508         $this->withAutodiscovery();
509         config()->set(['oidc.end_session_endpoint' => false]);
510
511         $this->mockHttpClient([
512             $this->getAutoDiscoveryResponse(),
513             $this->getJwksResponse(),
514         ]);
515
516         $resp = $this->asEditor()->post('/oidc/logout');
517         $resp->assertRedirect('/');
518         $this->assertFalse(auth()->check());
519     }
520
521     public function test_logout_without_autodiscovery_but_with_endpoint_configured()
522     {
523         config()->set(['oidc.end_session_endpoint' => 'https://example.com/logout']);
524
525         $resp = $this->asEditor()->post('/oidc/logout');
526         $resp->assertRedirect('https://example.com/logout?post_logout_redirect_uri=' . urlencode(url('/')));
527         $this->assertFalse(auth()->check());
528     }
529
530     public function test_logout_without_autodiscovery_with_configured_endpoint_adds_to_query_if_existing()
531     {
532         config()->set(['oidc.end_session_endpoint' => 'https://example.com/logout?a=b']);
533
534         $resp = $this->asEditor()->post('/oidc/logout');
535         $resp->assertRedirect('https://example.com/logout?a=b&post_logout_redirect_uri=' . urlencode(url('/')));
536         $this->assertFalse(auth()->check());
537     }
538
539     public function test_logout_with_autodiscovery_and_auto_initiate_returns_to_auto_prevented_login()
540     {
541         $this->withAutodiscovery();
542         config()->set([
543             'auth.auto_initiate' => true,
544             'services.google.client_id' => false,
545             'services.github.client_id' => false,
546             'oidc.end_session_endpoint' => true,
547         ]);
548
549         $this->mockHttpClient([
550             $this->getAutoDiscoveryResponse(),
551             $this->getJwksResponse(),
552         ]);
553
554         $resp = $this->asEditor()->post('/oidc/logout');
555
556         $redirectUrl = url('/login?prevent_auto_init=true');
557         $resp->assertRedirect('https://auth.example.com/oidc/logout?post_logout_redirect_uri=' . urlencode($redirectUrl));
558         $this->assertFalse(auth()->check());
559     }
560
561     public function test_logout_endpoint_url_overrides_autodiscovery_endpoint()
562     {
563         config()->set(['oidc.end_session_endpoint' => 'https://a.example.com']);
564         $this->withAutodiscovery();
565
566         $transactions = $this->mockHttpClient([
567             $this->getAutoDiscoveryResponse(),
568             $this->getJwksResponse(),
569         ]);
570
571         $resp = $this->asEditor()->post('/oidc/logout');
572         $resp->assertRedirect('https://a.example.com?post_logout_redirect_uri=' . urlencode(url('/')));
573
574         $this->assertEquals(2, $transactions->requestCount());
575         $this->assertFalse(auth()->check());
576     }
577
578     public function test_logout_with_autodiscovery_does_not_use_rp_logout_if_no_url_via_autodiscovery()
579     {
580         config()->set(['oidc.end_session_endpoint' => true]);
581         $this->withAutodiscovery();
582
583         $this->mockHttpClient([
584             $this->getAutoDiscoveryResponse(['end_session_endpoint' => null]),
585             $this->getJwksResponse(),
586         ]);
587
588         $resp = $this->asEditor()->post('/oidc/logout');
589         $resp->assertRedirect('/');
590         $this->assertFalse(auth()->check());
591     }
592
593     public function test_logout_redirect_contains_id_token_hint_if_existing()
594     {
595         config()->set(['oidc.end_session_endpoint' => 'https://example.com/logout']);
596
597         $this->runLogin();
598
599         $resp = $this->asEditor()->post('/oidc/logout');
600         $query = 'id_token_hint=' . urlencode(OidcJwtHelper::idToken()) .  '&post_logout_redirect_uri=' . urlencode(url('/'));
601         $resp->assertRedirect('https://example.com/logout?' . $query);
602     }
603
604     public function test_oidc_id_token_pre_validate_theme_event_without_return()
605     {
606         $args = [];
607         $callback = function (...$eventArgs) use (&$args) {
608             $args = $eventArgs;
609         };
610         Theme::listen(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $callback);
611
612         $resp = $this->runLogin([
613             'email' => '[email protected]',
614             'sub'   => 'benny1010101',
615             'name'  => 'Benny',
616         ]);
617         $resp->assertRedirect('/');
618
619         $this->assertDatabaseHas('users', [
620             'external_auth_id' => 'benny1010101',
621         ]);
622
623         $this->assertArrayHasKey('iss', $args[0]);
624         $this->assertArrayHasKey('sub', $args[0]);
625         $this->assertEquals('Benny', $args[0]['name']);
626         $this->assertEquals('benny1010101', $args[0]['sub']);
627
628         $this->assertArrayHasKey('access_token', $args[1]);
629         $this->assertArrayHasKey('expires_in', $args[1]);
630         $this->assertArrayHasKey('refresh_token', $args[1]);
631     }
632
633     public function test_oidc_id_token_pre_validate_theme_event_with_return()
634     {
635         $callback = function (...$eventArgs) {
636             return array_merge($eventArgs[0], [
637                 'email' => '[email protected]',
638                 'sub' => 'lenny1010101',
639                 'name' => 'Lenny',
640             ]);
641         };
642         Theme::listen(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $callback);
643
644         $resp = $this->runLogin([
645             'email' => '[email protected]',
646             'sub'   => 'benny1010101',
647             'name'  => 'Benny',
648         ]);
649         $resp->assertRedirect('/');
650
651         $this->assertDatabaseHas('users', [
652             'email' => '[email protected]',
653             'external_auth_id' => 'lenny1010101',
654             'name' => 'Lenny',
655         ]);
656     }
657
658     public function test_pkce_used_on_authorize_and_access()
659     {
660         // Start auth
661         $resp = $this->post('/oidc/login');
662         $state = session()->get('oidc_state');
663
664         $pkceCode = session()->get('oidc_pkce_code');
665         $this->assertGreaterThan(30, strlen($pkceCode));
666
667         $expectedCodeChallenge = trim(strtr(base64_encode(hash('sha256', $pkceCode, true)), '+/', '-_'), '=');
668         $redirect = $resp->headers->get('Location');
669         $redirectParams = [];
670         parse_str(parse_url($redirect, PHP_URL_QUERY), $redirectParams);
671         $this->assertEquals($expectedCodeChallenge, $redirectParams['code_challenge']);
672         $this->assertEquals('S256', $redirectParams['code_challenge_method']);
673
674         $transactions = $this->mockHttpClient([$this->getMockAuthorizationResponse([
675             'email' => '[email protected]',
676             'sub'   => 'benny1010101',
677         ])]);
678
679         $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
680         $tokenRequest = $transactions->latestRequest();
681         $bodyParams = [];
682         parse_str($tokenRequest->getBody(), $bodyParams);
683         $this->assertEquals($pkceCode, $bodyParams['code_verifier']);
684     }
685
686     protected function withAutodiscovery()
687     {
688         config()->set([
689             'oidc.issuer'                 => OidcJwtHelper::defaultIssuer(),
690             'oidc.discover'               => true,
691             'oidc.authorization_endpoint' => null,
692             'oidc.token_endpoint'         => null,
693             'oidc.jwt_public_key'         => null,
694         ]);
695     }
696
697     protected function runLogin($claimOverrides = []): TestResponse
698     {
699         $this->post('/oidc/login');
700         $state = session()->get('oidc_state');
701         $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]);
702
703         return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
704     }
705
706     protected function getAutoDiscoveryResponse($responseOverrides = []): Response
707     {
708         return new Response(200, [
709             'Content-Type'  => 'application/json',
710             'Cache-Control' => 'no-cache, no-store',
711             'Pragma'        => 'no-cache',
712         ], json_encode(array_merge([
713             'token_endpoint'         => OidcJwtHelper::defaultIssuer() . '/oidc/token',
714             'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize',
715             'jwks_uri'               => OidcJwtHelper::defaultIssuer() . '/oidc/keys',
716             'issuer'                 => OidcJwtHelper::defaultIssuer(),
717             'end_session_endpoint'   => OidcJwtHelper::defaultIssuer() . '/oidc/logout',
718         ], $responseOverrides)));
719     }
720
721     protected function getJwksResponse(): Response
722     {
723         return new Response(200, [
724             'Content-Type'  => 'application/json',
725             'Cache-Control' => 'no-cache, no-store',
726             'Pragma'        => 'no-cache',
727         ], json_encode([
728             'keys' => [
729                 OidcJwtHelper::publicJwkKeyArray(),
730             ],
731         ]));
732     }
733
734     protected function getMockAuthorizationResponse($claimOverrides = []): Response
735     {
736         return new Response(200, [
737             'Content-Type'  => 'application/json',
738             'Cache-Control' => 'no-cache, no-store',
739             'Pragma'        => 'no-cache',
740         ], json_encode([
741             'access_token' => 'abc123',
742             'token_type'   => 'Bearer',
743             'expires_in'   => 3600,
744             'id_token'     => OidcJwtHelper::idToken($claimOverrides),
745         ]));
746     }
747 }