]> BookStack Code Mirror - bookstack/blob - tests/Auth/OidcTest.php
Fixed OIDC handling when no JWKS 'use' prop exists
[bookstack] / tests / Auth / OidcTest.php
1 <?php
2
3 namespace Tests\Auth;
4
5 use BookStack\Actions\ActivityType;
6 use BookStack\Auth\Role;
7 use BookStack\Auth\User;
8 use GuzzleHttp\Psr7\Request;
9 use GuzzleHttp\Psr7\Response;
10 use Illuminate\Testing\TestResponse;
11 use Tests\Helpers\OidcJwtHelper;
12 use Tests\TestCase;
13
14 class OidcTest extends TestCase
15 {
16     protected string $keyFilePath;
17     protected $keyFile;
18
19     protected function setUp(): void
20     {
21         parent::setUp();
22         // Set default config for OpenID Connect
23
24         $this->keyFile = tmpfile();
25         $this->keyFilePath = 'file://' . stream_get_meta_data($this->keyFile)['uri'];
26         file_put_contents($this->keyFilePath, OidcJwtHelper::publicPemKey());
27
28         config()->set([
29             'auth.method'                 => 'oidc',
30             'auth.defaults.guard'         => 'oidc',
31             'oidc.name'                   => 'SingleSignOn-Testing',
32             'oidc.display_name_claims'    => ['name'],
33             'oidc.client_id'              => OidcJwtHelper::defaultClientId(),
34             'oidc.client_secret'          => 'testpass',
35             'oidc.jwt_public_key'         => $this->keyFilePath,
36             'oidc.issuer'                 => OidcJwtHelper::defaultIssuer(),
37             'oidc.authorization_endpoint' => 'https://oidc.local/auth',
38             'oidc.token_endpoint'         => 'https://oidc.local/token',
39             'oidc.discover'               => false,
40             'oidc.dump_user_details'      => false,
41             'oidc.additional_scopes'      => '',
42             'oidc.user_to_groups'         => false,
43             'oidc.groups_claim'           => 'group',
44             'oidc.remove_from_groups'     => false,
45         ]);
46     }
47
48     protected function tearDown(): void
49     {
50         parent::tearDown();
51         if (file_exists($this->keyFilePath)) {
52             unlink($this->keyFilePath);
53         }
54     }
55
56     public function test_login_option_shows_on_login_page()
57     {
58         $req = $this->get('/login');
59         $req->assertSeeText('SingleSignOn-Testing');
60         $this->withHtml($req)->assertElementExists('form[action$="/oidc/login"][method=POST] button');
61     }
62
63     public function test_oidc_routes_are_only_active_if_oidc_enabled()
64     {
65         config()->set(['auth.method' => 'standard']);
66         $routes = ['/login' => 'post', '/callback' => 'get'];
67         foreach ($routes as $uri => $method) {
68             $req = $this->call($method, '/oidc' . $uri);
69             $this->assertPermissionError($req);
70         }
71     }
72
73     public function test_forgot_password_routes_inaccessible()
74     {
75         $resp = $this->get('/password/email');
76         $this->assertPermissionError($resp);
77
78         $resp = $this->post('/password/email');
79         $this->assertPermissionError($resp);
80
81         $resp = $this->get('/password/reset/abc123');
82         $this->assertPermissionError($resp);
83
84         $resp = $this->post('/password/reset');
85         $this->assertPermissionError($resp);
86     }
87
88     public function test_standard_login_routes_inaccessible()
89     {
90         $resp = $this->post('/login');
91         $this->assertPermissionError($resp);
92     }
93
94     public function test_logout_route_functions()
95     {
96         $this->actingAs($this->getEditor());
97         $this->post('/logout');
98         $this->assertFalse(auth()->check());
99     }
100
101     public function test_user_invite_routes_inaccessible()
102     {
103         $resp = $this->get('/register/invite/abc123');
104         $this->assertPermissionError($resp);
105
106         $resp = $this->post('/register/invite/abc123');
107         $this->assertPermissionError($resp);
108     }
109
110     public function test_user_register_routes_inaccessible()
111     {
112         $resp = $this->get('/register');
113         $this->assertPermissionError($resp);
114
115         $resp = $this->post('/register');
116         $this->assertPermissionError($resp);
117     }
118
119     public function test_login()
120     {
121         $req = $this->post('/oidc/login');
122         $redirect = $req->headers->get('location');
123
124         $this->assertStringStartsWith('https://oidc.local/auth', $redirect, 'Login redirects to SSO location');
125         $this->assertFalse($this->isAuthenticated());
126         $this->assertStringContainsString('scope=openid%20profile%20email', $redirect);
127         $this->assertStringContainsString('client_id=' . OidcJwtHelper::defaultClientId(), $redirect);
128         $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $redirect);
129     }
130
131     public function test_login_success_flow()
132     {
133         // Start auth
134         $this->post('/oidc/login');
135         $state = session()->get('oidc_state');
136
137         $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([
138             'email' => '[email protected]',
139             'sub'   => 'benny1010101',
140         ])]);
141
142         // Callback from auth provider
143         // App calls token endpoint to get id token
144         $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
145         $resp->assertRedirect('/');
146         $this->assertCount(1, $transactions);
147         /** @var Request $tokenRequest */
148         $tokenRequest = $transactions[0]['request'];
149         $this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri());
150         $this->assertEquals('POST', $tokenRequest->getMethod());
151         $this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]);
152         $this->assertStringContainsString('grant_type=authorization_code', $tokenRequest->getBody());
153         $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody());
154         $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody());
155
156         $this->assertTrue(auth()->check());
157         $this->assertDatabaseHas('users', [
158             'email'            => '[email protected]',
159             'external_auth_id' => 'benny1010101',
160             'email_confirmed'  => false,
161         ]);
162
163         $user = User::query()->where('email', '=', '[email protected]')->first();
164         $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott");
165     }
166
167     public function test_login_uses_custom_additional_scopes_if_defined()
168     {
169         config()->set([
170             'oidc.additional_scopes' => 'groups, badgers',
171         ]);
172
173         $redirect = $this->post('/oidc/login')->headers->get('location');
174
175         $this->assertStringContainsString('scope=openid%20profile%20email%20groups%20badgers', $redirect);
176     }
177
178     public function test_callback_fails_if_no_state_present_or_matching()
179     {
180         $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
181         $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
182
183         $this->post('/oidc/login');
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
188     public function test_dump_user_details_option_outputs_as_expected()
189     {
190         config()->set('oidc.dump_user_details', true);
191
192         $resp = $this->runLogin([
193             'email' => '[email protected]',
194             'sub'   => 'benny505',
195         ]);
196
197         $resp->assertStatus(200);
198         $resp->assertJson([
199             'email' => '[email protected]',
200             'sub'   => 'benny505',
201             'iss'   => OidcJwtHelper::defaultIssuer(),
202             'aud'   => OidcJwtHelper::defaultClientId(),
203         ]);
204         $this->assertFalse(auth()->check());
205     }
206
207     public function test_auth_fails_if_no_email_exists_in_user_data()
208     {
209         $this->runLogin([
210             'email' => '',
211             'sub'   => 'benny505',
212         ]);
213
214         $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system');
215     }
216
217     public function test_auth_fails_if_already_logged_in()
218     {
219         $this->asEditor();
220
221         $this->runLogin([
222             'email' => '[email protected]',
223             'sub'   => 'benny505',
224         ]);
225
226         $this->assertSessionError('Already logged in');
227     }
228
229     public function test_auth_login_as_existing_user()
230     {
231         $editor = $this->getEditor();
232         $editor->external_auth_id = 'benny505';
233         $editor->save();
234
235         $this->assertFalse(auth()->check());
236
237         $this->runLogin([
238             'email' => '[email protected]',
239             'sub'   => 'benny505',
240         ]);
241
242         $this->assertTrue(auth()->check());
243         $this->assertEquals($editor->id, auth()->user()->id);
244     }
245
246     public function test_auth_login_as_existing_user_email_with_different_auth_id_fails()
247     {
248         $editor = $this->getEditor();
249         $editor->external_auth_id = 'editor101';
250         $editor->save();
251
252         $this->assertFalse(auth()->check());
253
254         $resp = $this->runLogin([
255             'email' => $editor->email,
256             'sub'   => 'benny505',
257         ]);
258         $resp = $this->followRedirects($resp);
259
260         $resp->assertSeeText('A user with the email ' . $editor->email . ' already exists but with different credentials.');
261         $this->assertFalse(auth()->check());
262     }
263
264     public function test_auth_login_with_invalid_token_fails()
265     {
266         $resp = $this->runLogin([
267             'sub' => null,
268         ]);
269         $resp = $this->followRedirects($resp);
270
271         $resp->assertSeeText('ID token validate failed with error: Missing token subject value');
272         $this->assertFalse(auth()->check());
273     }
274
275     public function test_auth_login_with_autodiscovery()
276     {
277         $this->withAutodiscovery();
278
279         $transactions = &$this->mockHttpClient([
280             $this->getAutoDiscoveryResponse(),
281             $this->getJwksResponse(),
282         ]);
283
284         $this->assertFalse(auth()->check());
285
286         $this->runLogin();
287
288         $this->assertTrue(auth()->check());
289         /** @var Request $discoverRequest */
290         $discoverRequest = $transactions[0]['request'];
291         /** @var Request $discoverRequest */
292         $keysRequest = $transactions[1]['request'];
293
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->assertCount(2, $transactions);
328         // Second run, hits cache
329         $this->post('/oidc/login');
330         $this->assertCount(2, $transactions);
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->assertCount(4, $transactions);
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_login_group_sync()
395     {
396         config()->set([
397             'oidc.user_to_groups'     => true,
398             'oidc.groups_claim'       => 'groups',
399             'oidc.remove_from_groups' => false,
400         ]);
401         $roleA = Role::factory()->create(['display_name' => 'Wizards']);
402         $roleB = Role::factory()->create(['display_name' => 'ZooFolks', 'external_auth_id' => 'zookeepers']);
403         $roleC = Role::factory()->create(['display_name' => 'Another Role']);
404
405         $resp = $this->runLogin([
406             'email'  => '[email protected]',
407             'sub'    => 'benny1010101',
408             'groups' => ['Wizards', 'Zookeepers'],
409         ]);
410         $resp->assertRedirect('/');
411
412         /** @var User $user */
413         $user = User::query()->where('email', '=', '[email protected]')->first();
414
415         $this->assertTrue($user->hasRole($roleA->id));
416         $this->assertTrue($user->hasRole($roleB->id));
417         $this->assertFalse($user->hasRole($roleC->id));
418     }
419
420     public function test_login_group_sync_with_nested_groups_in_token()
421     {
422         config()->set([
423             'oidc.user_to_groups'     => true,
424             'oidc.groups_claim'       => 'my.custom.groups.attr',
425             'oidc.remove_from_groups' => false,
426         ]);
427         $roleA = Role::factory()->create(['display_name' => 'Wizards']);
428
429         $resp = $this->runLogin([
430             'email'  => '[email protected]',
431             'sub'    => 'benny1010101',
432             'my'     => [
433                 'custom' => [
434                     'groups' => [
435                         'attr' => ['Wizards'],
436                     ],
437                 ],
438             ],
439         ]);
440         $resp->assertRedirect('/');
441
442         /** @var User $user */
443         $user = User::query()->where('email', '=', '[email protected]')->first();
444         $this->assertTrue($user->hasRole($roleA->id));
445     }
446
447     protected function withAutodiscovery()
448     {
449         config()->set([
450             'oidc.issuer'                 => OidcJwtHelper::defaultIssuer(),
451             'oidc.discover'               => true,
452             'oidc.authorization_endpoint' => null,
453             'oidc.token_endpoint'         => null,
454             'oidc.jwt_public_key'         => null,
455         ]);
456     }
457
458     protected function runLogin($claimOverrides = []): TestResponse
459     {
460         $this->post('/oidc/login');
461         $state = session()->get('oidc_state');
462         $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]);
463
464         return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
465     }
466
467     protected function getAutoDiscoveryResponse($responseOverrides = []): Response
468     {
469         return new Response(200, [
470             'Content-Type'  => 'application/json',
471             'Cache-Control' => 'no-cache, no-store',
472             'Pragma'        => 'no-cache',
473         ], json_encode(array_merge([
474             'token_endpoint'         => OidcJwtHelper::defaultIssuer() . '/oidc/token',
475             'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize',
476             'jwks_uri'               => OidcJwtHelper::defaultIssuer() . '/oidc/keys',
477             'issuer'                 => OidcJwtHelper::defaultIssuer(),
478         ], $responseOverrides)));
479     }
480
481     protected function getJwksResponse(): Response
482     {
483         return new Response(200, [
484             'Content-Type'  => 'application/json',
485             'Cache-Control' => 'no-cache, no-store',
486             'Pragma'        => 'no-cache',
487         ], json_encode([
488             'keys' => [
489                 OidcJwtHelper::publicJwkKeyArray(),
490             ],
491         ]));
492     }
493
494     protected function getMockAuthorizationResponse($claimOverrides = []): Response
495     {
496         return new Response(200, [
497             'Content-Type'  => 'application/json',
498             'Cache-Control' => 'no-cache, no-store',
499             'Pragma'        => 'no-cache',
500         ], json_encode([
501             'access_token' => 'abc123',
502             'token_type'   => 'Bearer',
503             'expires_in'   => 3600,
504             'id_token'     => OidcJwtHelper::idToken($claimOverrides),
505         ]));
506     }
507 }