]> BookStack Code Mirror - bookstack/blob - tests/Auth/OidcTest.php
Skip intermediate login page with single provider
[bookstack] / tests / Auth / OidcTest.php
1 <?php
2
3 namespace Tests\Auth;
4
5 use BookStack\Actions\ActivityType;
6 use BookStack\Auth\User;
7 use GuzzleHttp\Psr7\Request;
8 use GuzzleHttp\Psr7\Response;
9 use Tests\Helpers\OidcJwtHelper;
10 use Tests\TestCase;
11 use Tests\TestResponse;
12
13 class OidcTest extends TestCase
14 {
15     protected string $keyFilePath;
16     protected $keyFile;
17
18     protected function setUp(): void
19     {
20         parent::setUp();
21         // Set default config for OpenID Connect
22
23         $this->keyFile = tmpfile();
24         $this->keyFilePath = 'file://' . stream_get_meta_data($this->keyFile)['uri'];
25         file_put_contents($this->keyFilePath, OidcJwtHelper::publicPemKey());
26
27         config()->set([
28             'auth.method'                 => 'oidc',
29             'auth.auto_redirect'          => false,
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         ]);
42     }
43
44     protected function tearDown(): void
45     {
46         parent::tearDown();
47         if (file_exists($this->keyFilePath)) {
48             unlink($this->keyFilePath);
49         }
50     }
51
52     public function test_login_option_shows_on_login_page()
53     {
54         $req = $this->get('/login');
55         $req->assertSeeText('SingleSignOn-Testing');
56         $req->assertElementExists('form[action$="/oidc/login"][method=POST] button');
57     }
58
59     public function test_oidc_routes_are_only_active_if_oidc_enabled()
60     {
61         config()->set(['auth.method' => 'standard']);
62         $routes = ['/login' => 'post', '/callback' => 'get'];
63         foreach ($routes as $uri => $method) {
64             $req = $this->call($method, '/oidc' . $uri);
65             $this->assertPermissionError($req);
66         }
67     }
68
69     public function test_forgot_password_routes_inaccessible()
70     {
71         $resp = $this->get('/password/email');
72         $this->assertPermissionError($resp);
73
74         $resp = $this->post('/password/email');
75         $this->assertPermissionError($resp);
76
77         $resp = $this->get('/password/reset/abc123');
78         $this->assertPermissionError($resp);
79
80         $resp = $this->post('/password/reset');
81         $this->assertPermissionError($resp);
82     }
83
84     public function test_standard_login_routes_inaccessible()
85     {
86         $resp = $this->post('/login');
87         $this->assertPermissionError($resp);
88     }
89
90     public function test_logout_route_functions()
91     {
92         $this->actingAs($this->getEditor());
93         $this->post('/logout');
94         $this->assertFalse(auth()->check());
95     }
96
97     public function test_user_invite_routes_inaccessible()
98     {
99         $resp = $this->get('/register/invite/abc123');
100         $this->assertPermissionError($resp);
101
102         $resp = $this->post('/register/invite/abc123');
103         $this->assertPermissionError($resp);
104     }
105
106     public function test_user_register_routes_inaccessible()
107     {
108         $resp = $this->get('/register');
109         $this->assertPermissionError($resp);
110
111         $resp = $this->post('/register');
112         $this->assertPermissionError($resp);
113     }
114
115     public function test_automatic_redirect_on_login()
116     {
117         config()->set([
118             'auth.auto_redirect'        => true,
119             'services.google.client_id' => false,
120             'services.github.client_id' => false,
121         ]);
122         $req = $this->get('/login');
123         $req->assertSeeText('SingleSignOn-Testing');
124         $req->assertElementExists('form[action$="/oidc/login"][method=POST] button');
125         $req->assertElementExists('div#loginredirect-wrapper');
126     }
127
128     public function test_login()
129     {
130         $req = $this->post('/oidc/login');
131         $redirect = $req->headers->get('location');
132
133         $this->assertStringStartsWith('https://oidc.local/auth', $redirect, 'Login redirects to SSO location');
134         $this->assertFalse($this->isAuthenticated());
135         $this->assertStringContainsString('scope=openid%20profile%20email', $redirect);
136         $this->assertStringContainsString('client_id=' . OidcJwtHelper::defaultClientId(), $redirect);
137         $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $redirect);
138     }
139
140     public function test_login_success_flow()
141     {
142         // Start auth
143         $this->post('/oidc/login');
144         $state = session()->get('oidc_state');
145
146         $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([
147             'email' => 'benny@example.com',
148             'sub'   => 'benny1010101',
149         ])]);
150
151         // Callback from auth provider
152         // App calls token endpoint to get id token
153         $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
154         $resp->assertRedirect('/');
155         $this->assertCount(1, $transactions);
156         /** @var Request $tokenRequest */
157         $tokenRequest = $transactions[0]['request'];
158         $this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri());
159         $this->assertEquals('POST', $tokenRequest->getMethod());
160         $this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]);
161         $this->assertStringContainsString('grant_type=authorization_code', $tokenRequest->getBody());
162         $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody());
163         $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody());
164
165         $this->assertTrue(auth()->check());
166         $this->assertDatabaseHas('users', [
167             'email'            => 'benny@example.com',
168             'external_auth_id' => 'benny1010101',
169             'email_confirmed'  => false,
170         ]);
171
172         $user = User::query()->where('email', '=', 'benny@example.com')->first();
173         $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott");
174     }
175
176     public function test_callback_fails_if_no_state_present_or_matching()
177     {
178         $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
179         $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
180
181         $this->post('/oidc/login');
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
186     public function test_dump_user_details_option_outputs_as_expected()
187     {
188         config()->set('oidc.dump_user_details', true);
189
190         $resp = $this->runLogin([
191             'email' => 'benny@example.com',
192             'sub'   => 'benny505',
193         ]);
194
195         $resp->assertStatus(200);
196         $resp->assertJson([
197             'email' => 'benny@example.com',
198             'sub'   => 'benny505',
199             'iss'   => OidcJwtHelper::defaultIssuer(),
200             'aud'   => OidcJwtHelper::defaultClientId(),
201         ]);
202         $this->assertFalse(auth()->check());
203     }
204
205     public function test_auth_fails_if_no_email_exists_in_user_data()
206     {
207         $this->runLogin([
208             'email' => '',
209             'sub'   => 'benny505',
210         ]);
211
212         $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system');
213     }
214
215     public function test_auth_fails_if_already_logged_in()
216     {
217         $this->asEditor();
218
219         $this->runLogin([
220             'email' => 'benny@example.com',
221             'sub'   => 'benny505',
222         ]);
223
224         $this->assertSessionError('Already logged in');
225     }
226
227     public function test_auth_login_as_existing_user()
228     {
229         $editor = $this->getEditor();
230         $editor->external_auth_id = 'benny505';
231         $editor->save();
232
233         $this->assertFalse(auth()->check());
234
235         $this->runLogin([
236             'email' => 'benny@example.com',
237             'sub'   => 'benny505',
238         ]);
239
240         $this->assertTrue(auth()->check());
241         $this->assertEquals($editor->id, auth()->user()->id);
242     }
243
244     public function test_auth_login_as_existing_user_email_with_different_auth_id_fails()
245     {
246         $editor = $this->getEditor();
247         $editor->external_auth_id = 'editor101';
248         $editor->save();
249
250         $this->assertFalse(auth()->check());
251
252         $resp = $this->runLogin([
253             'email' => $editor->email,
254             'sub'   => 'benny505',
255         ]);
256         $resp = $this->followRedirects($resp);
257
258         $resp->assertSeeText('A user with the email ' . $editor->email . ' already exists but with different credentials.');
259         $this->assertFalse(auth()->check());
260     }
261
262     public function test_auth_login_with_invalid_token_fails()
263     {
264         $resp = $this->runLogin([
265             'sub' => null,
266         ]);
267         $resp = $this->followRedirects($resp);
268
269         $resp->assertSeeText('ID token validate failed with error: Missing token subject value');
270         $this->assertFalse(auth()->check());
271     }
272
273     public function test_auth_login_with_autodiscovery()
274     {
275         $this->withAutodiscovery();
276
277         $transactions = &$this->mockHttpClient([
278             $this->getAutoDiscoveryResponse(),
279             $this->getJwksResponse(),
280         ]);
281
282         $this->assertFalse(auth()->check());
283
284         $this->runLogin();
285
286         $this->assertTrue(auth()->check());
287         /** @var Request $discoverRequest */
288         $discoverRequest = $transactions[0]['request'];
289         /** @var Request $discoverRequest */
290         $keysRequest = $transactions[1]['request'];
291
292         $this->assertEquals('GET', $keysRequest->getMethod());
293         $this->assertEquals('GET', $discoverRequest->getMethod());
294         $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri());
295         $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/oidc/keys', $keysRequest->getUri());
296     }
297
298     public function test_auth_fails_if_autodiscovery_fails()
299     {
300         $this->withAutodiscovery();
301         $this->mockHttpClient([
302             new Response(404, [], 'Not found'),
303         ]);
304
305         $resp = $this->followRedirects($this->runLogin());
306         $this->assertFalse(auth()->check());
307         $resp->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
308     }
309
310     public function test_autodiscovery_calls_are_cached()
311     {
312         $this->withAutodiscovery();
313
314         $transactions = &$this->mockHttpClient([
315             $this->getAutoDiscoveryResponse(),
316             $this->getJwksResponse(),
317             $this->getAutoDiscoveryResponse([
318                 'issuer' => 'https://auto.example.com',
319             ]),
320             $this->getJwksResponse(),
321         ]);
322
323         // Initial run
324         $this->post('/oidc/login');
325         $this->assertCount(2, $transactions);
326         // Second run, hits cache
327         $this->post('/oidc/login');
328         $this->assertCount(2, $transactions);
329
330         // Third run, different issuer, new cache key
331         config()->set(['oidc.issuer' => 'https://auto.example.com']);
332         $this->post('/oidc/login');
333         $this->assertCount(4, $transactions);
334     }
335
336     public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_alg_property()
337     {
338         $this->withAutodiscovery();
339
340         $keyArray = OidcJwtHelper::publicJwkKeyArray();
341         unset($keyArray['alg']);
342
343         $this->mockHttpClient([
344             $this->getAutoDiscoveryResponse(),
345             new Response(200, [
346                 'Content-Type'  => 'application/json',
347                 'Cache-Control' => 'no-cache, no-store',
348                 'Pragma'        => 'no-cache',
349             ], json_encode([
350                 'keys' => [
351                     $keyArray,
352                 ],
353             ])),
354         ]);
355
356         $this->assertFalse(auth()->check());
357         $this->runLogin();
358         $this->assertTrue(auth()->check());
359     }
360
361     protected function withAutodiscovery()
362     {
363         config()->set([
364             'oidc.issuer'                 => OidcJwtHelper::defaultIssuer(),
365             'oidc.discover'               => true,
366             'oidc.authorization_endpoint' => null,
367             'oidc.token_endpoint'         => null,
368             'oidc.jwt_public_key'         => null,
369         ]);
370     }
371
372     protected function runLogin($claimOverrides = []): TestResponse
373     {
374         $this->post('/oidc/login');
375         $state = session()->get('oidc_state');
376         $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]);
377
378         return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
379     }
380
381     protected function getAutoDiscoveryResponse($responseOverrides = []): Response
382     {
383         return new Response(200, [
384             'Content-Type'  => 'application/json',
385             'Cache-Control' => 'no-cache, no-store',
386             'Pragma'        => 'no-cache',
387         ], json_encode(array_merge([
388             'token_endpoint'         => OidcJwtHelper::defaultIssuer() . '/oidc/token',
389             'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize',
390             'jwks_uri'               => OidcJwtHelper::defaultIssuer() . '/oidc/keys',
391             'issuer'                 => OidcJwtHelper::defaultIssuer(),
392         ], $responseOverrides)));
393     }
394
395     protected function getJwksResponse(): Response
396     {
397         return new Response(200, [
398             'Content-Type'  => 'application/json',
399             'Cache-Control' => 'no-cache, no-store',
400             'Pragma'        => 'no-cache',
401         ], json_encode([
402             'keys' => [
403                 OidcJwtHelper::publicJwkKeyArray(),
404             ],
405         ]));
406     }
407
408     protected function getMockAuthorizationResponse($claimOverrides = []): Response
409     {
410         return new Response(200, [
411             'Content-Type'  => 'application/json',
412             'Cache-Control' => 'no-cache, no-store',
413             'Pragma'        => 'no-cache',
414         ], json_encode([
415             'access_token' => 'abc123',
416             'token_type'   => 'Bearer',
417             'expires_in'   => 3600,
418             'id_token'     => OidcJwtHelper::idToken($claimOverrides),
419         ]));
420     }
421 }
Morty Proxy This is a proxified and sanitized view of the page, visit original site.