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;
11 use Tests\TestResponse;
13 class OidcTest extends TestCase
15 protected string $keyFilePath;
18 protected function setUp(): void
21 // Set default config for OpenID Connect
23 $this->keyFile = tmpfile();
24 $this->keyFilePath = 'file://' . stream_get_meta_data($this->keyFile)['uri'];
25 file_put_contents($this->keyFilePath, OidcJwtHelper::publicPemKey());
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,
44 protected function tearDown(): void
47 if (file_exists($this->keyFilePath)) {
48 unlink($this->keyFilePath);
52 public function test_login_option_shows_on_login_page()
54 $req = $this->get('/login');
55 $req->assertSeeText('SingleSignOn-Testing');
56 $req->assertElementExists('form[action$="/oidc/login"][method=POST] button');
59 public function test_oidc_routes_are_only_active_if_oidc_enabled()
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);
69 public function test_forgot_password_routes_inaccessible()
71 $resp = $this->get('/password/email');
72 $this->assertPermissionError($resp);
74 $resp = $this->post('/password/email');
75 $this->assertPermissionError($resp);
77 $resp = $this->get('/password/reset/abc123');
78 $this->assertPermissionError($resp);
80 $resp = $this->post('/password/reset');
81 $this->assertPermissionError($resp);
84 public function test_standard_login_routes_inaccessible()
86 $resp = $this->post('/login');
87 $this->assertPermissionError($resp);
90 public function test_logout_route_functions()
92 $this->actingAs($this->getEditor());
93 $this->post('/logout');
94 $this->assertFalse(auth()->check());
97 public function test_user_invite_routes_inaccessible()
99 $resp = $this->get('/register/invite/abc123');
100 $this->assertPermissionError($resp);
102 $resp = $this->post('/register/invite/abc123');
103 $this->assertPermissionError($resp);
106 public function test_user_register_routes_inaccessible()
108 $resp = $this->get('/register');
109 $this->assertPermissionError($resp);
111 $resp = $this->post('/register');
112 $this->assertPermissionError($resp);
115 public function test_automatic_redirect_on_login()
118 'auth.auto_redirect' => true,
119 'services.google.client_id' => false,
120 'services.github.client_id' => false,
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');
128 public function test_login()
130 $req = $this->post('/oidc/login');
131 $redirect = $req->headers->get('location');
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);
140 public function test_login_success_flow()
143 $this->post('/oidc/login');
144 $state = session()->get('oidc_state');
146 $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([
147 'email' => 'benny@example.com',
148 'sub' => 'benny1010101',
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());
165 $this->assertTrue(auth()->check());
166 $this->assertDatabaseHas('users', [
167 'email' => 'benny@example.com',
168 'external_auth_id' => 'benny1010101',
169 'email_confirmed' => false,
172 $user = User::query()->where('email', '=', 'benny@example.com')->first();
173 $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott");
176 public function test_callback_fails_if_no_state_present_or_matching()
178 $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
179 $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
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');
186 public function test_dump_user_details_option_outputs_as_expected()
188 config()->set('oidc.dump_user_details', true);
190 $resp = $this->runLogin([
191 'email' => 'benny@example.com',
195 $resp->assertStatus(200);
197 'email' => 'benny@example.com',
199 'iss' => OidcJwtHelper::defaultIssuer(),
200 'aud' => OidcJwtHelper::defaultClientId(),
202 $this->assertFalse(auth()->check());
205 public function test_auth_fails_if_no_email_exists_in_user_data()
212 $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system');
215 public function test_auth_fails_if_already_logged_in()
220 'email' => 'benny@example.com',
224 $this->assertSessionError('Already logged in');
227 public function test_auth_login_as_existing_user()
229 $editor = $this->getEditor();
230 $editor->external_auth_id = 'benny505';
233 $this->assertFalse(auth()->check());
236 'email' => 'benny@example.com',
240 $this->assertTrue(auth()->check());
241 $this->assertEquals($editor->id, auth()->user()->id);
244 public function test_auth_login_as_existing_user_email_with_different_auth_id_fails()
246 $editor = $this->getEditor();
247 $editor->external_auth_id = 'editor101';
250 $this->assertFalse(auth()->check());
252 $resp = $this->runLogin([
253 'email' => $editor->email,
256 $resp = $this->followRedirects($resp);
258 $resp->assertSeeText('A user with the email ' . $editor->email . ' already exists but with different credentials.');
259 $this->assertFalse(auth()->check());
262 public function test_auth_login_with_invalid_token_fails()
264 $resp = $this->runLogin([
267 $resp = $this->followRedirects($resp);
269 $resp->assertSeeText('ID token validate failed with error: Missing token subject value');
270 $this->assertFalse(auth()->check());
273 public function test_auth_login_with_autodiscovery()
275 $this->withAutodiscovery();
277 $transactions = &$this->mockHttpClient([
278 $this->getAutoDiscoveryResponse(),
279 $this->getJwksResponse(),
282 $this->assertFalse(auth()->check());
286 $this->assertTrue(auth()->check());
287 /** @var Request $discoverRequest */
288 $discoverRequest = $transactions[0]['request'];
289 /** @var Request $discoverRequest */
290 $keysRequest = $transactions[1]['request'];
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());
298 public function test_auth_fails_if_autodiscovery_fails()
300 $this->withAutodiscovery();
301 $this->mockHttpClient([
302 new Response(404, [], 'Not found'),
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');
310 public function test_autodiscovery_calls_are_cached()
312 $this->withAutodiscovery();
314 $transactions = &$this->mockHttpClient([
315 $this->getAutoDiscoveryResponse(),
316 $this->getJwksResponse(),
317 $this->getAutoDiscoveryResponse([
318 'issuer' => 'https://auto.example.com',
320 $this->getJwksResponse(),
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);
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);
336 public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_alg_property()
338 $this->withAutodiscovery();
340 $keyArray = OidcJwtHelper::publicJwkKeyArray();
341 unset($keyArray['alg']);
343 $this->mockHttpClient([
344 $this->getAutoDiscoveryResponse(),
346 'Content-Type' => 'application/json',
347 'Cache-Control' => 'no-cache, no-store',
348 'Pragma' => 'no-cache',
356 $this->assertFalse(auth()->check());
358 $this->assertTrue(auth()->check());
361 protected function withAutodiscovery()
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,
372 protected function runLogin($claimOverrides = []): TestResponse
374 $this->post('/oidc/login');
375 $state = session()->get('oidc_state');
376 $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]);
378 return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
381 protected function getAutoDiscoveryResponse($responseOverrides = []): Response
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)));
395 protected function getJwksResponse(): Response
397 return new Response(200, [
398 'Content-Type' => 'application/json',
399 'Cache-Control' => 'no-cache, no-store',
400 'Pragma' => 'no-cache',
403 OidcJwtHelper::publicJwkKeyArray(),
408 protected function getMockAuthorizationResponse($claimOverrides = []): Response
410 return new Response(200, [
411 'Content-Type' => 'application/json',
412 'Cache-Control' => 'no-cache, no-store',
413 'Pragma' => 'no-cache',
415 'access_token' => 'abc123',
416 'token_type' => 'Bearer',
417 'expires_in' => 3600,
418 'id_token' => OidcJwtHelper::idToken($claimOverrides),