5 use BookStack\Actions\ActivityType;
6 use BookStack\Auth\User;
7 use GuzzleHttp\Psr7\Request;
8 use GuzzleHttp\Psr7\Response;
9 use Illuminate\Filesystem\Cache;
10 use Tests\Helpers\OidcJwtHelper;
12 use Tests\TestResponse;
14 class OidcTest extends TestCase
16 protected $keyFilePath;
19 protected function setUp(): void
22 // Set default config for OpenID Connect
24 $this->keyFile = tmpfile();
25 $this->keyFilePath = 'file://' . stream_get_meta_data($this->keyFile)['uri'];
26 file_put_contents($this->keyFilePath, OidcJwtHelper::publicPemKey());
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,
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->get('/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_login()
117 $req = $this->post('/oidc/login');
118 $redirect = $req->headers->get('location');
120 $this->assertStringStartsWith('https://oidc.local/auth', $redirect, 'Login redirects to SSO location');
121 $this->assertFalse($this->isAuthenticated());
122 $this->assertStringContainsString('scope=openid%20profile%20email', $redirect);
123 $this->assertStringContainsString('client_id=' . OidcJwtHelper::defaultClientId(), $redirect);
124 $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $redirect);
127 public function test_login_success_flow()
130 $this->post('/oidc/login');
131 $state = session()->get('oidc_state');
133 $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([
135 'sub' => 'benny1010101',
138 // Callback from auth provider
139 // App calls token endpoint to get id token
140 $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
141 $resp->assertRedirect('/');
142 $this->assertCount(1, $transactions);
143 /** @var Request $tokenRequest */
144 $tokenRequest = $transactions[0]['request'];
145 $this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri());
146 $this->assertEquals('POST', $tokenRequest->getMethod());
147 $this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]);
148 $this->assertStringContainsString('grant_type=authorization_code', $tokenRequest->getBody());
149 $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody());
150 $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody());
152 $this->assertTrue(auth()->check());
153 $this->assertDatabaseHas('users', [
155 'external_auth_id' => 'benny1010101',
156 'email_confirmed' => false,
160 $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott");
163 public function test_callback_fails_if_no_state_present_or_matching()
165 $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
166 $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
168 $this->post('/oidc/login');
169 $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
170 $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
173 public function test_dump_user_details_option_outputs_as_expected()
175 config()->set('oidc.dump_user_details', true);
177 $resp = $this->runLogin([
182 $resp->assertStatus(200);
186 'iss' => OidcJwtHelper::defaultIssuer(),
187 'aud' => OidcJwtHelper::defaultClientId(),
189 $this->assertFalse(auth()->check());
192 public function test_auth_fails_if_no_email_exists_in_user_data()
199 $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system');
202 public function test_auth_fails_if_already_logged_in()
211 $this->assertSessionError('Already logged in');
214 public function test_auth_login_as_existing_user()
216 $editor = $this->getEditor();
217 $editor->external_auth_id = 'benny505';
220 $this->assertFalse(auth()->check());
227 $this->assertTrue(auth()->check());
228 $this->assertEquals($editor->id, auth()->user()->id);
231 public function test_auth_login_as_existing_user_email_with_different_auth_id_fails()
233 $editor = $this->getEditor();
234 $editor->external_auth_id = 'editor101';
237 $this->assertFalse(auth()->check());
240 'email' => $editor->email,
244 $this->assertSessionError('A user with the email ' . $editor->email . ' already exists but with different credentials.');
245 $this->assertFalse(auth()->check());
248 public function test_auth_login_with_invalid_token_fails()
254 $this->assertSessionError('ID token validate failed with error: Missing token subject value');
255 $this->assertFalse(auth()->check());
258 public function test_auth_login_with_autodiscovery()
260 $this->withAutodiscovery();
262 $transactions = &$this->mockHttpClient([
263 $this->getAutoDiscoveryResponse(),
264 $this->getJwksResponse(),
267 $this->assertFalse(auth()->check());
271 $this->assertTrue(auth()->check());
272 /** @var Request $discoverRequest */
273 $discoverRequest = $transactions[0]['request'];
274 /** @var Request $discoverRequest */
275 $keysRequest = $transactions[1]['request'];
277 $this->assertEquals('GET', $keysRequest->getMethod());
278 $this->assertEquals('GET', $discoverRequest->getMethod());
279 $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri());
280 $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/oidc/keys', $keysRequest->getUri());
283 public function test_auth_fails_if_autodiscovery_fails()
285 $this->withAutodiscovery();
286 $this->mockHttpClient([
287 new Response(404, [], 'Not found'),
291 $this->assertFalse(auth()->check());
292 $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
295 public function test_autodiscovery_calls_are_cached()
297 $this->withAutodiscovery();
299 $transactions = &$this->mockHttpClient([
300 $this->getAutoDiscoveryResponse(),
301 $this->getJwksResponse(),
302 $this->getAutoDiscoveryResponse([
303 'issuer' => 'https://auto.example.com',
305 $this->getJwksResponse(),
309 $this->post('/oidc/login');
310 $this->assertCount(2, $transactions);
311 // Second run, hits cache
312 $this->post('/oidc/login');
313 $this->assertCount(2, $transactions);
315 // Third run, different issuer, new cache key
316 config()->set(['oidc.issuer' => 'https://auto.example.com']);
317 $this->post('/oidc/login');
318 $this->assertCount(4, $transactions);
321 protected function withAutodiscovery()
324 'oidc.issuer' => OidcJwtHelper::defaultIssuer(),
325 'oidc.discover' => true,
326 'oidc.authorization_endpoint' => null,
327 'oidc.token_endpoint' => null,
328 'oidc.jwt_public_key' => null,
332 protected function runLogin($claimOverrides = []): TestResponse
334 $this->post('/oidc/login');
335 $state = session()->get('oidc_state');
336 $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]);
338 return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
341 protected function getAutoDiscoveryResponse($responseOverrides = []): Response
343 return new Response(200, [
344 'Content-Type' => 'application/json',
345 'Cache-Control' => 'no-cache, no-store',
346 'Pragma' => 'no-cache',
347 ], json_encode(array_merge([
348 'token_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/token',
349 'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize',
350 'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys',
351 'issuer' => OidcJwtHelper::defaultIssuer(),
352 ], $responseOverrides)));
355 protected function getJwksResponse(): Response
357 return new Response(200, [
358 'Content-Type' => 'application/json',
359 'Cache-Control' => 'no-cache, no-store',
360 'Pragma' => 'no-cache',
363 OidcJwtHelper::publicJwkKeyArray(),
368 protected function getMockAuthorizationResponse($claimOverrides = []): Response
370 return new Response(200, [
371 'Content-Type' => 'application/json',
372 'Cache-Control' => 'no-cache, no-store',
373 'Pragma' => 'no-cache',
375 'access_token' => 'abc123',
376 'token_type' => 'Bearer',
377 'expires_in' => 3600,
378 'id_token' => OidcJwtHelper::idToken($claimOverrides),