]> BookStack Code Mirror - bookstack/blob - tests/Auth/OidcTest.php
Added more complexity in an attempt to make ldap host failover fit
[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_login_group_sync()
364     {
365         config()->set([
366             'oidc.user_to_groups'     => true,
367             'oidc.groups_claim'       => 'groups',
368             'oidc.remove_from_groups' => false,
369         ]);
370         $roleA = Role::factory()->create(['display_name' => 'Wizards']);
371         $roleB = Role::factory()->create(['display_name' => 'ZooFolks', 'external_auth_id' => 'zookeepers']);
372         $roleC = Role::factory()->create(['display_name' => 'Another Role']);
373
374         $resp = $this->runLogin([
375             'email'  => '[email protected]',
376             'sub'    => 'benny1010101',
377             'groups' => ['Wizards', 'Zookeepers'],
378         ]);
379         $resp->assertRedirect('/');
380
381         /** @var User $user */
382         $user = User::query()->where('email', '=', '[email protected]')->first();
383
384         $this->assertTrue($user->hasRole($roleA->id));
385         $this->assertTrue($user->hasRole($roleB->id));
386         $this->assertFalse($user->hasRole($roleC->id));
387     }
388
389     public function test_login_group_sync_with_nested_groups_in_token()
390     {
391         config()->set([
392             'oidc.user_to_groups'     => true,
393             'oidc.groups_claim'       => 'my.custom.groups.attr',
394             'oidc.remove_from_groups' => false,
395         ]);
396         $roleA = Role::factory()->create(['display_name' => 'Wizards']);
397
398         $resp = $this->runLogin([
399             'email'  => '[email protected]',
400             'sub'    => 'benny1010101',
401             'my'     => [
402                 'custom' => [
403                     'groups' => [
404                         'attr' => ['Wizards'],
405                     ],
406                 ],
407             ],
408         ]);
409         $resp->assertRedirect('/');
410
411         /** @var User $user */
412         $user = User::query()->where('email', '=', '[email protected]')->first();
413         $this->assertTrue($user->hasRole($roleA->id));
414     }
415
416     protected function withAutodiscovery()
417     {
418         config()->set([
419             'oidc.issuer'                 => OidcJwtHelper::defaultIssuer(),
420             'oidc.discover'               => true,
421             'oidc.authorization_endpoint' => null,
422             'oidc.token_endpoint'         => null,
423             'oidc.jwt_public_key'         => null,
424         ]);
425     }
426
427     protected function runLogin($claimOverrides = []): TestResponse
428     {
429         $this->post('/oidc/login');
430         $state = session()->get('oidc_state');
431         $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]);
432
433         return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
434     }
435
436     protected function getAutoDiscoveryResponse($responseOverrides = []): Response
437     {
438         return new Response(200, [
439             'Content-Type'  => 'application/json',
440             'Cache-Control' => 'no-cache, no-store',
441             'Pragma'        => 'no-cache',
442         ], json_encode(array_merge([
443             'token_endpoint'         => OidcJwtHelper::defaultIssuer() . '/oidc/token',
444             'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize',
445             'jwks_uri'               => OidcJwtHelper::defaultIssuer() . '/oidc/keys',
446             'issuer'                 => OidcJwtHelper::defaultIssuer(),
447         ], $responseOverrides)));
448     }
449
450     protected function getJwksResponse(): Response
451     {
452         return new Response(200, [
453             'Content-Type'  => 'application/json',
454             'Cache-Control' => 'no-cache, no-store',
455             'Pragma'        => 'no-cache',
456         ], json_encode([
457             'keys' => [
458                 OidcJwtHelper::publicJwkKeyArray(),
459             ],
460         ]));
461     }
462
463     protected function getMockAuthorizationResponse($claimOverrides = []): Response
464     {
465         return new Response(200, [
466             'Content-Type'  => 'application/json',
467             'Cache-Control' => 'no-cache, no-store',
468             'Pragma'        => 'no-cache',
469         ], json_encode([
470             'access_token' => 'abc123',
471             'token_type'   => 'Bearer',
472             'expires_in'   => 3600,
473             'id_token'     => OidcJwtHelper::idToken($claimOverrides),
474         ]));
475     }
476 }