]> BookStack Code Mirror - bookstack/blob - tests/Auth/OidcTest.php
ESLINT: Added GH action and details to dev docs
[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             'oidc.external_id_claim'      => 'sub',
46         ]);
47     }
48
49     protected function tearDown(): void
50     {
51         parent::tearDown();
52         if (file_exists($this->keyFilePath)) {
53             unlink($this->keyFilePath);
54         }
55     }
56
57     public function test_login_option_shows_on_login_page()
58     {
59         $req = $this->get('/login');
60         $req->assertSeeText('SingleSignOn-Testing');
61         $this->withHtml($req)->assertElementExists('form[action$="/oidc/login"][method=POST] button');
62     }
63
64     public function test_oidc_routes_are_only_active_if_oidc_enabled()
65     {
66         config()->set(['auth.method' => 'standard']);
67         $routes = ['/login' => 'post', '/callback' => 'get'];
68         foreach ($routes as $uri => $method) {
69             $req = $this->call($method, '/oidc' . $uri);
70             $this->assertPermissionError($req);
71         }
72     }
73
74     public function test_forgot_password_routes_inaccessible()
75     {
76         $resp = $this->get('/password/email');
77         $this->assertPermissionError($resp);
78
79         $resp = $this->post('/password/email');
80         $this->assertPermissionError($resp);
81
82         $resp = $this->get('/password/reset/abc123');
83         $this->assertPermissionError($resp);
84
85         $resp = $this->post('/password/reset');
86         $this->assertPermissionError($resp);
87     }
88
89     public function test_standard_login_routes_inaccessible()
90     {
91         $resp = $this->post('/login');
92         $this->assertPermissionError($resp);
93     }
94
95     public function test_logout_route_functions()
96     {
97         $this->actingAs($this->users->editor());
98         $this->post('/logout');
99         $this->assertFalse(auth()->check());
100     }
101
102     public function test_user_invite_routes_inaccessible()
103     {
104         $resp = $this->get('/register/invite/abc123');
105         $this->assertPermissionError($resp);
106
107         $resp = $this->post('/register/invite/abc123');
108         $this->assertPermissionError($resp);
109     }
110
111     public function test_user_register_routes_inaccessible()
112     {
113         $resp = $this->get('/register');
114         $this->assertPermissionError($resp);
115
116         $resp = $this->post('/register');
117         $this->assertPermissionError($resp);
118     }
119
120     public function test_login()
121     {
122         $req = $this->post('/oidc/login');
123         $redirect = $req->headers->get('location');
124
125         $this->assertStringStartsWith('https://oidc.local/auth', $redirect, 'Login redirects to SSO location');
126         $this->assertFalse($this->isAuthenticated());
127         $this->assertStringContainsString('scope=openid%20profile%20email', $redirect);
128         $this->assertStringContainsString('client_id=' . OidcJwtHelper::defaultClientId(), $redirect);
129         $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $redirect);
130     }
131
132     public function test_login_success_flow()
133     {
134         // Start auth
135         $this->post('/oidc/login');
136         $state = session()->get('oidc_state');
137
138         $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([
139             'email' => '[email protected]',
140             'sub'   => 'benny1010101',
141         ])]);
142
143         // Callback from auth provider
144         // App calls token endpoint to get id token
145         $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
146         $resp->assertRedirect('/');
147         $this->assertCount(1, $transactions);
148         /** @var Request $tokenRequest */
149         $tokenRequest = $transactions[0]['request'];
150         $this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri());
151         $this->assertEquals('POST', $tokenRequest->getMethod());
152         $this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]);
153         $this->assertStringContainsString('grant_type=authorization_code', $tokenRequest->getBody());
154         $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody());
155         $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody());
156
157         $this->assertTrue(auth()->check());
158         $this->assertDatabaseHas('users', [
159             'email'            => '[email protected]',
160             'external_auth_id' => 'benny1010101',
161             'email_confirmed'  => false,
162         ]);
163
164         $user = User::query()->where('email', '=', '[email protected]')->first();
165         $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott");
166     }
167
168     public function test_login_uses_custom_additional_scopes_if_defined()
169     {
170         config()->set([
171             'oidc.additional_scopes' => 'groups, badgers',
172         ]);
173
174         $redirect = $this->post('/oidc/login')->headers->get('location');
175
176         $this->assertStringContainsString('scope=openid%20profile%20email%20groups%20badgers', $redirect);
177     }
178
179     public function test_callback_fails_if_no_state_present_or_matching()
180     {
181         $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
182         $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
183
184         $this->post('/oidc/login');
185         $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
186         $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
187     }
188
189     public function test_dump_user_details_option_outputs_as_expected()
190     {
191         config()->set('oidc.dump_user_details', true);
192
193         $resp = $this->runLogin([
194             'email' => '[email protected]',
195             'sub'   => 'benny505',
196         ]);
197
198         $resp->assertStatus(200);
199         $resp->assertJson([
200             'email' => '[email protected]',
201             'sub'   => 'benny505',
202             'iss'   => OidcJwtHelper::defaultIssuer(),
203             'aud'   => OidcJwtHelper::defaultClientId(),
204         ]);
205         $this->assertFalse(auth()->check());
206     }
207
208     public function test_auth_fails_if_no_email_exists_in_user_data()
209     {
210         $this->runLogin([
211             'email' => '',
212             'sub'   => 'benny505',
213         ]);
214
215         $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system');
216     }
217
218     public function test_auth_fails_if_already_logged_in()
219     {
220         $this->asEditor();
221
222         $this->runLogin([
223             'email' => '[email protected]',
224             'sub'   => 'benny505',
225         ]);
226
227         $this->assertSessionError('Already logged in');
228     }
229
230     public function test_auth_login_as_existing_user()
231     {
232         $editor = $this->users->editor();
233         $editor->external_auth_id = 'benny505';
234         $editor->save();
235
236         $this->assertFalse(auth()->check());
237
238         $this->runLogin([
239             'email' => '[email protected]',
240             'sub'   => 'benny505',
241         ]);
242
243         $this->assertTrue(auth()->check());
244         $this->assertEquals($editor->id, auth()->user()->id);
245     }
246
247     public function test_auth_login_as_existing_user_email_with_different_auth_id_fails()
248     {
249         $editor = $this->users->editor();
250         $editor->external_auth_id = 'editor101';
251         $editor->save();
252
253         $this->assertFalse(auth()->check());
254
255         $resp = $this->runLogin([
256             'email' => $editor->email,
257             'sub'   => 'benny505',
258         ]);
259         $resp = $this->followRedirects($resp);
260
261         $resp->assertSeeText('A user with the email ' . $editor->email . ' already exists but with different credentials.');
262         $this->assertFalse(auth()->check());
263     }
264
265     public function test_auth_login_with_invalid_token_fails()
266     {
267         $resp = $this->runLogin([
268             'sub' => null,
269         ]);
270         $resp = $this->followRedirects($resp);
271
272         $resp->assertSeeText('ID token validate failed with error: Missing token subject value');
273         $this->assertFalse(auth()->check());
274     }
275
276     public function test_auth_login_with_autodiscovery()
277     {
278         $this->withAutodiscovery();
279
280         $transactions = &$this->mockHttpClient([
281             $this->getAutoDiscoveryResponse(),
282             $this->getJwksResponse(),
283         ]);
284
285         $this->assertFalse(auth()->check());
286
287         $this->runLogin();
288
289         $this->assertTrue(auth()->check());
290         /** @var Request $discoverRequest */
291         $discoverRequest = $transactions[0]['request'];
292         /** @var Request $discoverRequest */
293         $keysRequest = $transactions[1]['request'];
294
295         $this->assertEquals('GET', $keysRequest->getMethod());
296         $this->assertEquals('GET', $discoverRequest->getMethod());
297         $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri());
298         $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/oidc/keys', $keysRequest->getUri());
299     }
300
301     public function test_auth_fails_if_autodiscovery_fails()
302     {
303         $this->withAutodiscovery();
304         $this->mockHttpClient([
305             new Response(404, [], 'Not found'),
306         ]);
307
308         $resp = $this->followRedirects($this->runLogin());
309         $this->assertFalse(auth()->check());
310         $resp->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
311     }
312
313     public function test_autodiscovery_calls_are_cached()
314     {
315         $this->withAutodiscovery();
316
317         $transactions = &$this->mockHttpClient([
318             $this->getAutoDiscoveryResponse(),
319             $this->getJwksResponse(),
320             $this->getAutoDiscoveryResponse([
321                 'issuer' => 'https://auto.example.com',
322             ]),
323             $this->getJwksResponse(),
324         ]);
325
326         // Initial run
327         $this->post('/oidc/login');
328         $this->assertCount(2, $transactions);
329         // Second run, hits cache
330         $this->post('/oidc/login');
331         $this->assertCount(2, $transactions);
332
333         // Third run, different issuer, new cache key
334         config()->set(['oidc.issuer' => 'https://auto.example.com']);
335         $this->post('/oidc/login');
336         $this->assertCount(4, $transactions);
337     }
338
339     public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_alg_property()
340     {
341         $this->withAutodiscovery();
342
343         $keyArray = OidcJwtHelper::publicJwkKeyArray();
344         unset($keyArray['alg']);
345
346         $this->mockHttpClient([
347             $this->getAutoDiscoveryResponse(),
348             new Response(200, [
349                 'Content-Type'  => 'application/json',
350                 'Cache-Control' => 'no-cache, no-store',
351                 'Pragma'        => 'no-cache',
352             ], json_encode([
353                 'keys' => [
354                     $keyArray,
355                 ],
356             ])),
357         ]);
358
359         $this->assertFalse(auth()->check());
360         $this->runLogin();
361         $this->assertTrue(auth()->check());
362     }
363
364     public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_use_property()
365     {
366         // Based on reading the OIDC discovery spec:
367         // > This contains the signing key(s) the RP uses to validate signatures from the OP. The JWK Set MAY also
368         // > contain the Server's encryption key(s), which are used by RPs to encrypt requests to the Server. When
369         // > both signing and encryption keys are made available, a use (Key Use) parameter value is REQUIRED for all
370         // > keys in the referenced JWK Set to indicate each key's intended usage.
371         // We can assume that keys without use are intended for signing.
372         $this->withAutodiscovery();
373
374         $keyArray = OidcJwtHelper::publicJwkKeyArray();
375         unset($keyArray['use']);
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_uses_configured_external_id_claim_option()
396     {
397         config()->set([
398             'oidc.external_id_claim' => 'super_awesome_id',
399         ]);
400         $roleA = Role::factory()->create(['display_name' => 'Wizards']);
401
402         $resp = $this->runLogin([
403             'email'            => '[email protected]',
404             'sub'              => 'benny1010101',
405             'super_awesome_id' => 'xXBennyTheGeezXx',
406         ]);
407         $resp->assertRedirect('/');
408
409         /** @var User $user */
410         $user = User::query()->where('email', '=', '[email protected]')->first();
411         $this->assertEquals('xXBennyTheGeezXx', $user->external_auth_id);
412     }
413
414     public function test_login_group_sync()
415     {
416         config()->set([
417             'oidc.user_to_groups'     => true,
418             'oidc.groups_claim'       => 'groups',
419             'oidc.remove_from_groups' => false,
420         ]);
421         $roleA = Role::factory()->create(['display_name' => 'Wizards']);
422         $roleB = Role::factory()->create(['display_name' => 'ZooFolks', 'external_auth_id' => 'zookeepers']);
423         $roleC = Role::factory()->create(['display_name' => 'Another Role']);
424
425         $resp = $this->runLogin([
426             'email'  => '[email protected]',
427             'sub'    => 'benny1010101',
428             'groups' => ['Wizards', 'Zookeepers'],
429         ]);
430         $resp->assertRedirect('/');
431
432         /** @var User $user */
433         $user = User::query()->where('email', '=', '[email protected]')->first();
434
435         $this->assertTrue($user->hasRole($roleA->id));
436         $this->assertTrue($user->hasRole($roleB->id));
437         $this->assertFalse($user->hasRole($roleC->id));
438     }
439
440     public function test_login_group_sync_with_nested_groups_in_token()
441     {
442         config()->set([
443             'oidc.user_to_groups'     => true,
444             'oidc.groups_claim'       => 'my.custom.groups.attr',
445             'oidc.remove_from_groups' => false,
446         ]);
447         $roleA = Role::factory()->create(['display_name' => 'Wizards']);
448
449         $resp = $this->runLogin([
450             'email'  => '[email protected]',
451             'sub'    => 'benny1010101',
452             'my'     => [
453                 'custom' => [
454                     'groups' => [
455                         'attr' => ['Wizards'],
456                     ],
457                 ],
458             ],
459         ]);
460         $resp->assertRedirect('/');
461
462         /** @var User $user */
463         $user = User::query()->where('email', '=', '[email protected]')->first();
464         $this->assertTrue($user->hasRole($roleA->id));
465     }
466
467     protected function withAutodiscovery()
468     {
469         config()->set([
470             'oidc.issuer'                 => OidcJwtHelper::defaultIssuer(),
471             'oidc.discover'               => true,
472             'oidc.authorization_endpoint' => null,
473             'oidc.token_endpoint'         => null,
474             'oidc.jwt_public_key'         => null,
475         ]);
476     }
477
478     protected function runLogin($claimOverrides = []): TestResponse
479     {
480         $this->post('/oidc/login');
481         $state = session()->get('oidc_state');
482         $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]);
483
484         return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
485     }
486
487     protected function getAutoDiscoveryResponse($responseOverrides = []): Response
488     {
489         return new Response(200, [
490             'Content-Type'  => 'application/json',
491             'Cache-Control' => 'no-cache, no-store',
492             'Pragma'        => 'no-cache',
493         ], json_encode(array_merge([
494             'token_endpoint'         => OidcJwtHelper::defaultIssuer() . '/oidc/token',
495             'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize',
496             'jwks_uri'               => OidcJwtHelper::defaultIssuer() . '/oidc/keys',
497             'issuer'                 => OidcJwtHelper::defaultIssuer(),
498         ], $responseOverrides)));
499     }
500
501     protected function getJwksResponse(): Response
502     {
503         return new Response(200, [
504             'Content-Type'  => 'application/json',
505             'Cache-Control' => 'no-cache, no-store',
506             'Pragma'        => 'no-cache',
507         ], json_encode([
508             'keys' => [
509                 OidcJwtHelper::publicJwkKeyArray(),
510             ],
511         ]));
512     }
513
514     protected function getMockAuthorizationResponse($claimOverrides = []): Response
515     {
516         return new Response(200, [
517             'Content-Type'  => 'application/json',
518             'Cache-Control' => 'no-cache, no-store',
519             'Pragma'        => 'no-cache',
520         ], json_encode([
521             'access_token' => 'abc123',
522             'token_type'   => 'Bearer',
523             'expires_in'   => 3600,
524             'id_token'     => OidcJwtHelper::idToken($claimOverrides),
525         ]));
526     }
527 }