diff --git a/.env.example.complete b/.env.example.complete index e8520a24cae..1242968182a 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -267,6 +267,7 @@ OIDC_ISSUER_DISCOVER=false OIDC_PUBLIC_KEY=null OIDC_AUTH_ENDPOINT=null OIDC_TOKEN_ENDPOINT=null +OIDC_USERINFO_ENDPOINT=null OIDC_ADDITIONAL_SCOPES=null OIDC_DUMP_USER_DETAILS=false OIDC_USER_TO_GROUPS=false diff --git a/app/Access/Oidc/OidcProviderSettings.php b/app/Access/Oidc/OidcProviderSettings.php index bea6a523e77..49ccab6f0de 100644 --- a/app/Access/Oidc/OidcProviderSettings.php +++ b/app/Access/Oidc/OidcProviderSettings.php @@ -22,6 +22,7 @@ class OidcProviderSettings public ?string $authorizationEndpoint; public ?string $tokenEndpoint; public ?string $endSessionEndpoint; + public ?string $userinfoEndpoint; /** * @var string[]|array[] @@ -128,6 +129,10 @@ protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): $discoveredSettings['tokenEndpoint'] = $result['token_endpoint']; } + if (!empty($result['userinfo_endpoint'])) { + $discoveredSettings['userinfoEndpoint'] = $result['userinfo_endpoint']; + } + if (!empty($result['jwks_uri'])) { $keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient); $discoveredSettings['keys'] = $this->filterKeys($keys); @@ -177,7 +182,7 @@ protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): ar */ public function arrayForProvider(): array { - $settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint']; + $settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint', 'userinfoEndpoint']; $settings = []; foreach ($settingKeys as $setting) { $settings[$setting] = $this->$setting; diff --git a/app/Access/Oidc/OidcService.php b/app/Access/Oidc/OidcService.php index f1e5b25af14..24495799195 100644 --- a/app/Access/Oidc/OidcService.php +++ b/app/Access/Oidc/OidcService.php @@ -85,6 +85,7 @@ protected function getProviderSettings(): OidcProviderSettings 'authorizationEndpoint' => $config['authorization_endpoint'], 'tokenEndpoint' => $config['token_endpoint'], 'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null, + 'userinfoEndpoint' => $config['userinfo_endpoint'], ]); // Use keys if configured @@ -228,6 +229,17 @@ protected function processAccessTokenCallback(OidcAccessToken $accessToken, Oidc session()->put("oidc_id_token", $idTokenText); + if (!empty($settings->userinfoEndpoint)) { + $provider = $this->getProvider($settings); + $request = $provider->getAuthenticatedRequest('GET', $settings->userinfoEndpoint, $accessToken->getToken()); + $response = $provider->getParsedResponse($request); + $claims = $idToken->getAllClaims(); + foreach ($response as $key => $value) { + $claims[$key] = $value; + } + $idToken->replaceClaims($claims); + } + $returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [ 'access_token' => $accessToken->getToken(), 'expires_in' => $accessToken->getExpires(), diff --git a/app/Config/oidc.php b/app/Config/oidc.php index 7f8f40d4190..8b5470931d0 100644 --- a/app/Config/oidc.php +++ b/app/Config/oidc.php @@ -35,6 +35,7 @@ // OAuth2 endpoints. 'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null), 'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null), + 'userinfo_endpoint' => env('OIDC_USERINFO_ENDPOINT', null), // OIDC RP-Initiated Logout endpoint URL. // A false value force-disables RP-Initiated Logout. diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php index dbf26f1bd30..f47a201005d 100644 --- a/tests/Auth/OidcTest.php +++ b/tests/Auth/OidcTest.php @@ -668,11 +668,44 @@ protected function withAutodiscovery() protected function runLogin($claimOverrides = []): TestResponse { + // These two variables should perhaps be arguments instead of + // assuming that they're tied to whether discovery is enabled, + // but that's how the tests are written for now. + $claimsInIdToken = !config('oidc.discover'); + $tokenEndpoint = config('oidc.discover') + ? OidcJwtHelper::defaultIssuer() . '/oidc/token' + : 'https://oidc.local/token'; + $this->post('/oidc/login'); $state = session()->get('oidc_state'); - $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]); - return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); + $providerResponses = [$this->getMockAuthorizationResponse($claimsInIdToken ? $claimOverrides : [])]; + if (!$claimsInIdToken) { + $providerResponses[] = new Response(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache, no-store', + 'Pragma' => 'no-cache', + ], json_encode($claimOverrides)); + } + + $transactions = $this->mockHttpClient($providerResponses); + + $response = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); + + if (auth()->check()) { + $this->assertEquals($claimsInIdToken ? 1 : 2, $transactions->requestCount()); + $tokenRequest = $transactions->requestAt(0); + $this->assertEquals($tokenEndpoint, (string) $tokenRequest->getUri()); + $this->assertEquals('POST', $tokenRequest->getMethod()); + if (!$claimsInIdToken) { + $userinfoRequest = $transactions->requestAt(1); + $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/oidc/userinfo', (string) $userinfoRequest->getUri()); + $this->assertEquals('GET', $userinfoRequest->getMethod()); + $this->assertEquals('Bearer abc123', $userinfoRequest->getHeader('Authorization')[0]); + } + } + + return $response; } protected function getAutoDiscoveryResponse($responseOverrides = []): Response @@ -684,6 +717,7 @@ protected function getAutoDiscoveryResponse($responseOverrides = []): Response ], json_encode(array_merge([ 'token_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/token', 'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize', + 'userinfo_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/userinfo', 'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys', 'issuer' => OidcJwtHelper::defaultIssuer(), 'end_session_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/logout',