]> BookStack Code Mirror - bookstack/blobdiff - app/Access/Oidc/OidcService.php
Layout: Converted tri-layout component to ts
[bookstack] / app / Access / Oidc / OidcService.php
index 1067b0832d44086e41b58cafe25521ec939385e1..d6f6ef156e40020ba162b2f2bd00587d7afb74c5 100644 (file)
@@ -11,8 +11,8 @@ use BookStack\Exceptions\UserRegistrationException;
 use BookStack\Facades\Theme;
 use BookStack\Http\HttpRequestService;
 use BookStack\Theming\ThemeEvents;
+use BookStack\Uploads\UserAvatars;
 use BookStack\Users\Models\User;
-use Illuminate\Support\Arr;
 use Illuminate\Support\Facades\Cache;
 use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
 use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
@@ -27,12 +27,15 @@ class OidcService
         protected RegistrationService $registrationService,
         protected LoginService $loginService,
         protected HttpRequestService $http,
-        protected GroupSyncService $groupService
+        protected GroupSyncService $groupService,
+        protected UserAvatars $userAvatars
     ) {
     }
 
     /**
      * Initiate an authorization flow.
+     * Provides back an authorize redirect URL, in addition to other
+     * details which may be required for the auth flow.
      *
      * @throws OidcException
      *
@@ -42,8 +45,12 @@ class OidcService
     {
         $settings = $this->getProviderSettings();
         $provider = $this->getProvider($settings);
+
+        $url = $provider->getAuthorizationUrl();
+        session()->put('oidc_pkce_code', $provider->getPkceCode() ?? '');
+
         return [
-            'url'   => $provider->getAuthorizationUrl(),
+            'url'   => $url,
             'state' => $provider->getState(),
         ];
     }
@@ -63,6 +70,10 @@ class OidcService
         $settings = $this->getProviderSettings();
         $provider = $this->getProvider($settings);
 
+        // Set PKCE code flashed at login
+        $pkceCode = session()->pull('oidc_pkce_code', '');
+        $provider->setPkceCode($pkceCode);
+
         // Try to exchange authorization code for access token
         $accessToken = $provider->getAccessToken('authorization_code', [
             'code' => $authorizationCode,
@@ -81,9 +92,10 @@ class OidcService
             'issuer'                => $config['issuer'],
             'clientId'              => $config['client_id'],
             'clientSecret'          => $config['client_secret'],
-            'redirectUri'           => url('/oidc/callback'),
             '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
@@ -100,6 +112,14 @@ class OidcService
             }
         }
 
+        // Prevent use of RP-initiated logout if specifically disabled
+        // Or force use of a URL if specifically set.
+        if ($config['end_session_endpoint'] === false) {
+            $settings->endSessionEndpoint = null;
+        } else if (is_string($config['end_session_endpoint'])) {
+            $settings->endSessionEndpoint = $config['end_session_endpoint'];
+        }
+
         $settings->validate();
 
         return $settings;
@@ -110,7 +130,10 @@ class OidcService
      */
     protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
     {
-        $provider = new OidcOAuthProvider($settings->arrayForProvider(), [
+        $provider = new OidcOAuthProvider([
+            ...$settings->arrayForOAuthProvider(),
+            'redirectUri' => url('/oidc/callback'),
+        ], [
             'httpClient'     => $this->http->buildClient(5),
             'optionProvider' => new HttpBasicAuthOptionProvider(),
         ]);
@@ -137,69 +160,6 @@ class OidcService
         return array_filter($scopeArr);
     }
 
-    /**
-     * Calculate the display name.
-     */
-    protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
-    {
-        $displayNameAttrString = $this->config()['display_name_claims'] ?? '';
-        $displayNameAttrs = explode('|', $displayNameAttrString);
-
-        $displayName = [];
-        foreach ($displayNameAttrs as $dnAttr) {
-            $dnComponent = $token->getClaim($dnAttr) ?? '';
-            if ($dnComponent !== '') {
-                $displayName[] = $dnComponent;
-            }
-        }
-
-        if (count($displayName) == 0) {
-            $displayName[] = $defaultValue;
-        }
-
-        return implode(' ', $displayName);
-    }
-
-    /**
-     * Extract the assigned groups from the id token.
-     *
-     * @return string[]
-     */
-    protected function getUserGroups(OidcIdToken $token): array
-    {
-        $groupsAttr = $this->config()['groups_claim'];
-        if (empty($groupsAttr)) {
-            return [];
-        }
-
-        $groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
-        if (!is_array($groupsList)) {
-            return [];
-        }
-
-        return array_values(array_filter($groupsList, function ($val) {
-            return is_string($val);
-        }));
-    }
-
-    /**
-     * Extract the details of a user from an ID token.
-     *
-     * @return array{name: string, email: string, external_id: string, groups: string[]}
-     */
-    protected function getUserDetails(OidcIdToken $token): array
-    {
-        $idClaim = $this->config()['external_id_claim'];
-        $id = $token->getClaim($idClaim);
-
-        return [
-            'external_id' => $id,
-            'email'       => $token->getClaim('email'),
-            'name'        => $this->getUserDisplayName($token, $id),
-            'groups'      => $this->getUserGroups($token),
-        ];
-    }
-
     /**
      * Processes a received access token for a user. Login the user when
      * they exist, optionally registering them automatically.
@@ -217,11 +177,7 @@ class OidcService
             $settings->keys,
         );
 
-        // OIDC Logout Feature: Temporarily save token in session 
-        $access_token_for_logout = $idTokenText;
-        session()->put("oidctoken", $access_token_for_logout);
-
-
+        session()->put("oidc_id_token", $idTokenText);
 
         $returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [
             'access_token' => $accessToken->getToken(),
@@ -240,34 +196,39 @@ class OidcService
         try {
             $idToken->validate($settings->clientId);
         } catch (OidcInvalidTokenException $exception) {
-            throw new OidcException("ID token validate failed with error: {$exception->getMessage()}");
+            throw new OidcException("ID token validation failed with error: {$exception->getMessage()}");
         }
 
-        $userDetails = $this->getUserDetails($idToken);
-        $isLoggedIn = auth()->check();
-
-        if (empty($userDetails['email'])) {
+        $userDetails = $this->getUserDetailsFromToken($idToken, $accessToken, $settings);
+        if (empty($userDetails->email)) {
             throw new OidcException(trans('errors.oidc_no_email_address'));
         }
+        if (empty($userDetails->name)) {
+            $userDetails->name = $userDetails->externalId;
+        }
 
+        $isLoggedIn = auth()->check();
         if ($isLoggedIn) {
             throw new OidcException(trans('errors.oidc_already_logged_in'));
         }
 
         try {
             $user = $this->registrationService->findOrRegister(
-                $userDetails['name'],
-                $userDetails['email'],
-                $userDetails['external_id']
+                $userDetails->name,
+                $userDetails->email,
+                $userDetails->externalId
             );
         } catch (UserRegistrationException $exception) {
             throw new OidcException($exception->getMessage());
         }
 
+        if ($this->config()['fetch_avatar'] && !$user->avatar()->exists() && $userDetails->picture) {
+            $this->userAvatars->assignToUserFromUrl($user, $userDetails->picture);
+        }
+
         if ($this->shouldSyncGroups()) {
-            $groups = $userDetails['groups'];
             $detachExisting = $this->config()['remove_from_groups'];
-            $this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
+            $this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting);
         }
 
         $this->loginService->login($user, 'oidc');
@@ -275,6 +236,45 @@ class OidcService
         return $user;
     }
 
+    /**
+     * @throws OidcException
+     */
+    protected function getUserDetailsFromToken(OidcIdToken $idToken, OidcAccessToken $accessToken, OidcProviderSettings $settings): OidcUserDetails
+    {
+        $userDetails = new OidcUserDetails();
+        $userDetails->populate(
+            $idToken,
+            $this->config()['external_id_claim'],
+            $this->config()['display_name_claims'] ?? '',
+            $this->config()['groups_claim'] ?? ''
+        );
+
+        if (!$userDetails->isFullyPopulated($this->shouldSyncGroups()) && !empty($settings->userinfoEndpoint)) {
+            $provider = $this->getProvider($settings);
+            $request = $provider->getAuthenticatedRequest('GET', $settings->userinfoEndpoint, $accessToken->getToken());
+            $response = new OidcUserinfoResponse(
+                $provider->getResponse($request),
+                $settings->issuer,
+                $settings->keys,
+            );
+
+            try {
+                $response->validate($idToken->getClaim('sub'), $settings->clientId);
+            } catch (OidcInvalidTokenException $exception) {
+                throw new OidcException("Userinfo endpoint response validation failed with error: {$exception->getMessage()}");
+            }
+
+            $userDetails->populate(
+                $response,
+                $this->config()['external_id_claim'],
+                $this->config()['display_name_claims'] ?? '',
+                $this->config()['groups_claim'] ?? ''
+            );
+        }
+
+        return $userDetails;
+    }
+
     /**
      * Get the OIDC config from the application.
      */
@@ -291,36 +291,29 @@ class OidcService
         return $this->config()['user_to_groups'] !== false;
     }
 
-
     /**
-     * OIDC Logout Feature: Initiate a logout flow.
-     *
+     * Start the RP-initiated logout flow if active, otherwise start a standard logout flow.
+     * Returns a post-app-logout redirect URL.
+     * Reference: https://openid.net/specs/openid-connect-rpinitiated-1_0.html
      * @throws OidcException
-     *
-     * @return string
      */
-    public function logout() {
-
-        $config = $this->config();
-        $app_url = env('APP_URL', '');
-        $end_session_endpoint = $config["end_session_endpoint"];
-
-        $oidctoken = session()->get("oidctoken");
-        session()->invalidate();
+    public function logout(): string
+    {
+        $oidcToken = session()->pull("oidc_id_token");
+        $defaultLogoutUrl = url($this->loginService->logout());
+        $oidcSettings = $this->getProviderSettings();
 
-        if (str_contains($app_url, 'https://')) { 
-             $protocol = 'https://';
-        } else {
-             $protocol = 'http://';
+        if (!$oidcSettings->endSessionEndpoint) {
+            return $defaultLogoutUrl;
         }
 
+        $endpointParams = [
+            'id_token_hint' => $oidcToken,
+            'post_logout_redirect_uri' => $defaultLogoutUrl,
+        ];
 
+        $joiner = str_contains($oidcSettings->endSessionEndpoint, '?') ? '&' : '?';
 
-        return redirect($end_session_endpoint.'?id_token_hint='.$oidctoken."&post_logout_redirect_uri=".$protocol.$_SERVER['HTTP_HOST']."/");
-
-
+        return $oidcSettings->endSessionEndpoint . $joiner . http_build_query($endpointParams);
     }
-
-
-
 }