View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.sso.entraid;
17  
18  import static org.codelibs.core.stream.StreamUtil.split;
19  
20  import java.io.IOException;
21  import java.net.URI;
22  import java.net.URLEncoder;
23  import java.util.ArrayList;
24  import java.util.Arrays;
25  import java.util.Collections;
26  import java.util.HashMap;
27  import java.util.List;
28  import java.util.Locale;
29  import java.util.Map;
30  import java.util.concurrent.ExecutionException;
31  import java.util.concurrent.TimeUnit;
32  import java.util.stream.Collectors;
33  
34  import org.apache.commons.lang3.StringUtils;
35  import org.apache.logging.log4j.LogManager;
36  import org.apache.logging.log4j.Logger;
37  import org.codelibs.core.lang.StringUtil;
38  import org.codelibs.core.misc.Pair;
39  import org.codelibs.core.net.UuidUtil;
40  import org.codelibs.core.stream.StreamUtil;
41  import org.codelibs.core.timer.TimeoutManager;
42  import org.codelibs.curl.Curl;
43  import org.codelibs.curl.CurlResponse;
44  import org.codelibs.fess.app.web.base.login.ActionResponseCredential;
45  import org.codelibs.fess.app.web.base.login.EntraIdCredential;
46  import org.codelibs.fess.app.web.base.login.EntraIdCredential.EntraIdUser;
47  import org.codelibs.fess.app.web.base.login.FessLoginAssist.LoginCredentialResolver;
48  import org.codelibs.fess.crawler.Constants;
49  import org.codelibs.fess.exception.SsoLoginException;
50  import org.codelibs.fess.mylasta.action.FessUserBean;
51  import org.codelibs.fess.mylasta.direction.FessConfig;
52  import org.codelibs.fess.sso.SsoAuthenticator;
53  import org.codelibs.fess.sso.SsoResponseType;
54  import org.codelibs.fess.util.ComponentUtil;
55  import org.codelibs.fess.util.DocumentUtil;
56  import org.codelibs.opensearch.runner.net.OpenSearchCurl;
57  import org.dbflute.optional.OptionalEntity;
58  import org.dbflute.optional.OptionalThing;
59  import org.lastaflute.web.login.credential.LoginCredential;
60  import org.lastaflute.web.response.ActionResponse;
61  import org.lastaflute.web.response.HtmlResponse;
62  import org.lastaflute.web.util.LaRequestUtil;
63  
64  import com.google.common.cache.Cache;
65  import com.google.common.cache.CacheBuilder;
66  import com.microsoft.aad.msal4j.AuthorizationCodeParameters;
67  import com.microsoft.aad.msal4j.ConfidentialClientApplication;
68  import com.microsoft.aad.msal4j.IAuthenticationResult;
69  import com.microsoft.aad.msal4j.RefreshTokenParameters;
70  import com.microsoft.aad.msal4j.SilentParameters;
71  import com.nimbusds.jwt.JWTClaimsSet;
72  import com.nimbusds.jwt.JWTParser;
73  import com.nimbusds.oauth2.sdk.AuthorizationCode;
74  import com.nimbusds.openid.connect.sdk.AuthenticationErrorResponse;
75  import com.nimbusds.openid.connect.sdk.AuthenticationResponse;
76  import com.nimbusds.openid.connect.sdk.AuthenticationResponseParser;
77  import com.nimbusds.openid.connect.sdk.AuthenticationSuccessResponse;
78  
79  import jakarta.annotation.PostConstruct;
80  import jakarta.servlet.http.HttpServletRequest;
81  import jakarta.servlet.http.HttpSession;
82  
83  /**
84   * Microsoft Entra ID SSO authenticator implementation.
85   * Handles OAuth2/OpenID Connect authentication flow with Entra ID.
86   */
87  public class EntraIdAuthenticator implements SsoAuthenticator {
88  
89      private static final Logger logger = LogManager.getLogger(EntraIdAuthenticator.class);
90  
91      /**
92       * Default constructor for EntraIdAuthenticator.
93       */
94      public EntraIdAuthenticator() {
95          // Default constructor
96      }
97  
98      // New configuration keys for Entra ID
99      /** Configuration key for Entra ID state time-to-live. */
100     protected static final String ENTRAID_STATE_TTL = "entraid.state.ttl";
101 
102     /** Configuration key for Entra ID authority URL. */
103     protected static final String ENTRAID_AUTHORITY = "entraid.authority";
104 
105     /** Configuration key for Entra ID tenant ID. */
106     protected static final String ENTRAID_TENANT = "entraid.tenant";
107 
108     /** Configuration key for Entra ID client secret. */
109     protected static final String ENTRAID_CLIENT_SECRET = "entraid.client.secret";
110 
111     /** Configuration key for Entra ID client ID. */
112     protected static final String ENTRAID_CLIENT_ID = "entraid.client.id";
113 
114     /** Configuration key for Entra ID reply URL. */
115     protected static final String ENTRAID_REPLY_URL = "entraid.reply.url";
116 
117     /** Configuration key for Entra ID default groups. */
118     protected static final String ENTRAID_DEFAULT_GROUPS = "entraid.default.groups";
119 
120     /** Configuration key for Entra ID default roles. */
121     protected static final String ENTRAID_DEFAULT_ROLES = "entraid.default.roles";
122 
123     // Legacy configuration keys for backward compatibility (Azure AD)
124     /** Legacy configuration key for Azure AD state time-to-live. */
125     protected static final String AAD_STATE_TTL = "aad.state.ttl";
126 
127     /** Legacy configuration key for Azure AD authority URL. */
128     protected static final String AAD_AUTHORITY = "aad.authority";
129 
130     /** Legacy configuration key for Azure AD tenant ID. */
131     protected static final String AAD_TENANT = "aad.tenant";
132 
133     /** Legacy configuration key for Azure AD client secret. */
134     protected static final String AAD_CLIENT_SECRET = "aad.client.secret";
135 
136     /** Legacy configuration key for Azure AD client ID. */
137     protected static final String AAD_CLIENT_ID = "aad.client.id";
138 
139     /** Legacy configuration key for Azure AD reply URL. */
140     protected static final String AAD_REPLY_URL = "aad.reply.url";
141 
142     /** Legacy configuration key for Azure AD default groups. */
143     protected static final String AAD_DEFAULT_GROUPS = "aad.default.groups";
144 
145     /** Legacy configuration key for Azure AD default roles. */
146     protected static final String AAD_DEFAULT_ROLES = "aad.default.roles";
147 
148     /** Session attribute key for storing Entra ID states. */
149     protected static final String STATES = "entraidStates";
150 
151     /** OAuth2 state parameter name. */
152     protected static final String STATE = "state";
153 
154     /** OAuth2 error parameter name. */
155     protected static final String ERROR = "error";
156 
157     /** OAuth2 error description parameter name. */
158     protected static final String ERROR_DESCRIPTION = "error_description";
159 
160     /** OAuth2 error URI parameter name. */
161     protected static final String ERROR_URI = "error_uri";
162 
163     /** OpenID Connect ID token parameter name. */
164     protected static final String ID_TOKEN = "id_token";
165 
166     /** OAuth2 authorization code parameter name. */
167     protected static final String CODE = "code";
168 
169     /** Timeout for token acquisition in milliseconds. */
170     protected long acquisitionTimeout = 30 * 1000L;
171 
172     /** Cache for storing group information to reduce API calls. */
173     protected Cache<String, Pair<String[], String[]>> groupCache;
174 
175     /** Group cache expiry time in seconds. */
176     protected long groupCacheExpiry = 10 * 60L;
177 
178     /** Maximum depth for processing nested groups to prevent infinite loops. */
179     protected int maxGroupDepth = 10;
180 
181     /** Use V2 endpoint. */
182     protected boolean useV2Endpoint = true;
183 
184     /**
185      * Initializes the Entra ID authenticator.
186      * Registers this authenticator with the SSO manager and sets up group cache.
187      */
188     @PostConstruct
189     public void init() {
190         if (logger.isDebugEnabled()) {
191             logger.debug("Initializing {}", this.getClass().getSimpleName());
192         }
193         ComponentUtil.getSsoManager().register(this);
194         groupCache = CacheBuilder.newBuilder().expireAfterWrite(groupCacheExpiry, TimeUnit.SECONDS).build();
195     }
196 
197     @Override
198     public LoginCredential getLoginCredential() {
199         return LaRequestUtil.getOptionalRequest().map(request -> {
200             if (logger.isDebugEnabled()) {
201                 logger.debug("Logging in with Entra ID Authenticator");
202             }
203             final HttpSession session = request.getSession(false);
204             if (session != null && containsAuthenticationData(request)) {
205                 try {
206                     return processAuthenticationData(request);
207                 } catch (final Exception e) {
208                     if (logger.isDebugEnabled()) {
209                         logger.debug("Failed to process a login request on Entra ID.", e);
210                     }
211                 }
212                 return null;
213             }
214 
215             return new ActionResponseCredential(() -> HtmlResponse.fromRedirectPathAsIs(getAuthUrl(request)));
216         }).orElse(null);
217     }
218 
219     /**
220      * Generates the Entra ID authorization URL for the authentication request.
221      * @param request The HTTP servlet request.
222      * @return The authorization URL to redirect the user to.
223      */
224     protected String getAuthUrl(final HttpServletRequest request) {
225         final String state = UuidUtil.create();
226         final String nonce = UuidUtil.create();
227         storeStateInSession(request.getSession(), state, nonce);
228         final String authUrl;
229 
230         if (useV2Endpoint) {
231             // v2.0 endpoint with MSAL4J (recommended)
232             authUrl = getAuthority() + getTenant()
233                     + "/oauth2/v2.0/authorize?response_type=code&scope=https://graph.microsoft.com/.default&response_mode=form_post&redirect_uri="
234                     + URLEncoder.encode(getReplyUrl(request), Constants.UTF_8_CHARSET) + "&client_id=" + getClientId() + "&state=" + state
235                     + "&nonce=" + nonce;
236         } else {
237             // v1.0 endpoint for backward compatibility
238             authUrl = getAuthority() + getTenant()
239                     + "/oauth2/authorize?response_type=code&scope=directory.read.all&response_mode=form_post&redirect_uri="
240                     + URLEncoder.encode(getReplyUrl(request), Constants.UTF_8_CHARSET) + "&client_id=" + getClientId()
241                     + "&resource=https%3a%2f%2fgraph.microsoft.com" + "&state=" + state + "&nonce=" + nonce;
242         }
243         if (logger.isDebugEnabled()) {
244             logger.debug("redirect to: {} (using {} endpoint)", authUrl, useV2Endpoint ? "v2.0" : "v1.0");
245         }
246         return authUrl;
247 
248     }
249 
250     /**
251      * Stores state and nonce information in the HTTP session.
252      * @param session The HTTP session.
253      * @param state The OAuth2 state parameter.
254      * @param nonce The OpenID Connect nonce parameter.
255      */
256     protected void storeStateInSession(final HttpSession session, final String state, final String nonce) {
257         @SuppressWarnings("unchecked")
258         Map<String, StateData> stateMap = (Map<String, StateData>) session.getAttribute(STATES);
259         if (stateMap == null) {
260             stateMap = new HashMap<>();
261             session.setAttribute(STATES, stateMap);
262         }
263         final StateData stateData = new StateData(nonce, ComponentUtil.getSystemHelper().getCurrentTimeAsLong());
264         if (logger.isDebugEnabled()) {
265             logger.debug("Storing state in session: {}", stateData);
266         }
267         stateMap.put(state, stateData);
268     }
269 
270     /**
271      * Processes authentication data from the OAuth2 callback.
272      * @param request The HTTP servlet request containing authentication data.
273      * @return The login credential or null if processing fails.
274      */
275     protected LoginCredential processAuthenticationData(final HttpServletRequest request) {
276         final StringBuilder urlBuf = new StringBuilder(request.getRequestURL());
277         final String queryStr = request.getQueryString();
278         if (queryStr != null) {
279             urlBuf.append('?').append(queryStr);
280         }
281 
282         final Map<String, List<String>> params = new HashMap<>();
283         for (final Map.Entry<String, String[]> e : request.getParameterMap().entrySet()) {
284             if (e.getValue().length > 0) {
285                 params.put(e.getKey(), Arrays.asList(e.getValue()));
286             }
287         }
288         if (logger.isDebugEnabled()) {
289             logger.debug("process authentication: url: {}, params: {}", urlBuf, params);
290         }
291 
292         // validate that state in response equals to state in request
293         final StateData stateData = validateState(request.getSession(), params.containsKey(STATE) ? params.get(STATE).get(0) : null);
294         if (logger.isDebugEnabled()) {
295             logger.debug("Loading state: {}", stateData);
296         }
297 
298         final AuthenticationResponse authResponse = parseAuthenticationResponse(urlBuf.toString(), params);
299         if (authResponse instanceof final AuthenticationSuccessResponse oidcResponse) {
300             validateAuthRespMatchesCodeFlow(oidcResponse);
301             final IAuthenticationResult authData = getAccessToken(oidcResponse.getAuthorizationCode(), getReplyUrl(request));
302             validateNonce(stateData, authData);
303 
304             return new EntraIdCredential(authData);
305         }
306         final AuthenticationErrorResponse oidcResponse = (AuthenticationErrorResponse) authResponse;
307         throw new SsoLoginException(String.format("Request for auth code failed: %s - %s", oidcResponse.getErrorObject().getCode(),
308                 oidcResponse.getErrorObject().getDescription()));
309     }
310 
311     /**
312      * Parses the authentication response from Entra ID.
313      * @param url The response URL.
314      * @param params The response parameters.
315      * @return The parsed authentication response.
316      */
317     protected AuthenticationResponse parseAuthenticationResponse(final String url, final Map<String, List<String>> params) {
318         if (logger.isDebugEnabled()) {
319             logger.debug("Parse: {} : {}", url, params);
320         }
321         try {
322             return AuthenticationResponseParser.parse(new URI(url), params);
323         } catch (final Exception e) {
324             throw new SsoLoginException("Failed to parse an authentication response.", e);
325         }
326     }
327 
328     /**
329      * Validates the nonce in the authentication result.
330      * @param stateData The stored state data containing the expected nonce.
331      * @param authData The authentication result containing the actual nonce.
332      */
333     protected void validateNonce(final StateData stateData, final IAuthenticationResult authData) {
334         final String idToken = authData.idToken();
335         if (logger.isDebugEnabled()) {
336             logger.debug("idToken={}***", idToken.substring(0, Math.min(8, idToken.length())));
337         }
338         try {
339             final JWTClaimsSet claimsSet = JWTParser.parse(idToken).getJWTClaimsSet();
340             if (claimsSet == null) {
341                 throw new SsoLoginException("could not validate nonce");
342             }
343 
344             final String nonce = (String) claimsSet.getClaim("nonce");
345             if (logger.isDebugEnabled()) {
346                 logger.debug("nonce={}", nonce);
347             }
348             if (StringUtils.isEmpty(nonce) || !nonce.equals(stateData.getNonce())) {
349                 throw new SsoLoginException("could not validate nonce");
350             }
351         } catch (final SsoLoginException e) {
352             throw e;
353         } catch (final Exception e) {
354             throw new SsoLoginException("could not validate nonce", e);
355         }
356     }
357 
358     /**
359      * Obtains an access token using a refresh token.
360      * @param refreshToken The refresh token to use for token acquisition.
361      * @return The authentication result containing the access token.
362      */
363     public IAuthenticationResult getAccessToken(final String refreshToken) {
364         final String authority = getAuthority() + getTenant() + "/";
365         if (logger.isDebugEnabled()) {
366             logger.debug("refreshToken={}***, authority={}", refreshToken.substring(0, Math.min(8, refreshToken.length())), authority);
367         }
368         try {
369             final ConfidentialClientApplication app = ConfidentialClientApplication
370                     .builder(getClientId(), com.microsoft.aad.msal4j.ClientCredentialFactory.createFromSecret(getClientSecret()))
371                     .authority(authority)
372                     .build();
373 
374             final RefreshTokenParameters parameters =
375                     RefreshTokenParameters.builder(Collections.singleton("https://graph.microsoft.com/.default"), refreshToken).build();
376 
377             final IAuthenticationResult result = app.acquireToken(parameters).get(acquisitionTimeout, TimeUnit.MILLISECONDS);
378             if (result == null) {
379                 throw new SsoLoginException("authentication result was null");
380             }
381             return result;
382         } catch (final Exception e) {
383             throw new SsoLoginException("Failed to get a token.", e);
384         }
385     }
386 
387     /**
388      * Obtains an access token using an authorization code.
389      * @param authorizationCode The authorization code received from Entra ID.
390      * @param currentUri The current URI for the redirect.
391      * @return The authentication result containing the access token.
392      */
393     protected IAuthenticationResult getAccessToken(final AuthorizationCode authorizationCode, final String currentUri) {
394         final String authority = getAuthority() + getTenant() + "/";
395         final String authCode = authorizationCode.getValue();
396         if (logger.isDebugEnabled()) {
397             logger.debug("authCode={}, authority={}, uri={}", authCode, authority, currentUri);
398         }
399         try {
400             final ConfidentialClientApplication app = ConfidentialClientApplication
401                     .builder(getClientId(), com.microsoft.aad.msal4j.ClientCredentialFactory.createFromSecret(getClientSecret()))
402                     .authority(authority)
403                     .build();
404 
405             final AuthorizationCodeParameters parameters = AuthorizationCodeParameters.builder(authCode, new URI(currentUri))
406                     .scopes(Collections.singleton("https://graph.microsoft.com/.default"))
407                     .build();
408 
409             final IAuthenticationResult result = app.acquireToken(parameters).get(acquisitionTimeout, TimeUnit.MILLISECONDS);
410             if (result == null) {
411                 throw new SsoLoginException("authentication result was null");
412             }
413             return result;
414         } catch (final Exception e) {
415             throw new SsoLoginException("Failed to get a token.", e);
416         }
417     }
418 
419     /**
420      * Attempts to refresh tokens silently using the MSAL4J silent authentication flow.
421      * @param user The Entra ID user whose tokens need to be refreshed.
422      * @return The new authentication result, or null if silent refresh failed.
423      */
424     public IAuthenticationResult refreshTokenSilently(final EntraIdCredential.EntraIdUser user) {
425         final String authority = getAuthority() + getTenant() + "/";
426         try {
427             final ConfidentialClientApplication app = ConfidentialClientApplication
428                     .builder(getClientId(), com.microsoft.aad.msal4j.ClientCredentialFactory.createFromSecret(getClientSecret()))
429                     .authority(authority)
430                     .build();
431 
432             final SilentParameters parameters = SilentParameters
433                     .builder(Collections.singleton("https://graph.microsoft.com/.default"), user.getAuthenticationResult().account())
434                     .build();
435 
436             final IAuthenticationResult result = app.acquireTokenSilently(parameters).get(acquisitionTimeout, TimeUnit.MILLISECONDS);
437             if (logger.isDebugEnabled()) {
438                 logger.debug("Silent token acquisition successful");
439             }
440             return result;
441         } catch (final Exception e) {
442             if (logger.isDebugEnabled()) {
443                 logger.debug("Silent token acquisition failed: {}", e.getMessage());
444             }
445             return null;
446         }
447     }
448 
449     /**
450      * Validates that the authentication response matches the authorization code flow.
451      * @param oidcResponse The OpenID Connect authentication success response.
452      */
453     protected void validateAuthRespMatchesCodeFlow(final AuthenticationSuccessResponse oidcResponse) {
454         if (oidcResponse.getIDToken() != null || oidcResponse.getAccessToken() != null || oidcResponse.getAuthorizationCode() == null) {
455             throw new SsoLoginException("unexpected set of artifacts received");
456         }
457     }
458 
459     /**
460      * Validates the OAuth2 state parameter.
461      * @param session The HTTP session containing stored state data.
462      * @param state The state parameter to validate.
463      * @return The validated state data.
464      */
465     protected StateData validateState(final HttpSession session, final String state) {
466         if (StringUtils.isNotEmpty(state)) {
467             final StateData stateDataInSession = removeStateFromSession(session, state);
468             if (stateDataInSession != null) {
469                 return stateDataInSession;
470             }
471         }
472         throw new SsoLoginException("could not validate state");
473     }
474 
475     /**
476      * Removes and returns state data from the HTTP session.
477      * @param session The HTTP session.
478      * @param state The state parameter to remove.
479      * @return The removed state data or null if not found.
480      */
481     protected StateData removeStateFromSession(final HttpSession session, final String state) {
482         @SuppressWarnings("unchecked")
483         final Map<String, StateData> states = (Map<String, StateData>) session.getAttribute(STATES);
484         if (states != null) {
485             final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
486             states.entrySet()
487                     .stream()
488                     .filter(e -> (now - e.getValue().getExpiration()) / 1000L > getStateTtl())
489                     .map(Map.Entry::getKey)
490                     .collect(Collectors.toList())
491                     .forEach(s -> {
492                         if (logger.isDebugEnabled()) {
493                             logger.debug("Removing old state: {}", s);
494                         }
495                         states.remove(s);
496                     });
497             final StateData stateData = states.get(state);
498             if (stateData != null) {
499                 if (logger.isDebugEnabled()) {
500                     logger.debug("Restoring state from session: {}", stateData);
501                 }
502                 states.remove(state);
503                 return stateData;
504             }
505         }
506         return null;
507     }
508 
509     /**
510      * Checks if the request contains authentication data from Entra ID.
511      * @param request The HTTP servlet request to check.
512      * @return True if authentication data is present, false otherwise.
513      */
514     protected boolean containsAuthenticationData(final HttpServletRequest request) {
515         if (logger.isDebugEnabled()) {
516             logger.debug("HTTP Method: {}", request.getMethod());
517         }
518         if (!"POST".equalsIgnoreCase(request.getMethod())) {
519             return false;
520         }
521         final Map<String, String[]> params = request.getParameterMap();
522         if (logger.isDebugEnabled()) {
523             logger.debug("params={}", params);
524         }
525         return params.containsKey(ERROR) || params.containsKey(ID_TOKEN) || params.containsKey(CODE);
526     }
527 
528     /**
529      * Updates the user's group and role membership information with lazy loading for parent groups.
530      * Direct groups are retrieved synchronously, while parent groups are fetched asynchronously
531      * to avoid login delays when users have many nested group memberships.
532      * @param user The Entra ID user to update.
533      */
534     public void updateMemberOf(final EntraIdUser user) {
535         if (logger.isDebugEnabled()) {
536             logger.debug("[updateMemberOf] Starting for user: {}", user.getName());
537         }
538 
539         final List<String> groupList = new ArrayList<>();
540         final List<String> roleList = new ArrayList<>();
541         final List<String> groupIdsForParentLookup = new ArrayList<>();
542 
543         final List<String> defaultGroups = getDefaultGroupList();
544         final List<String> defaultRoles = getDefaultRoleList();
545         groupList.addAll(defaultGroups);
546         roleList.addAll(defaultRoles);
547 
548         if (logger.isDebugEnabled()) {
549             logger.debug("[updateMemberOf] Default groups: {}, Default roles: {}", defaultGroups, defaultRoles);
550         }
551 
552         // Retrieve direct groups synchronously (parent group lookup is deferred)
553         processDirectMemberOf(user, groupList, roleList, groupIdsForParentLookup, "https://graph.microsoft.com/v1.0/me/memberOf");
554 
555         if (logger.isDebugEnabled()) {
556             logger.debug("[updateMemberOf] Direct groups retrieved. Total groups: {}, Total roles: {}, Group IDs for parent lookup: {}",
557                     groupList.size(), roleList.size(), groupIdsForParentLookup.size());
558         }
559 
560         // Set initial groups
561         user.setGroups(groupList.stream().distinct().toArray(n -> new String[n]));
562         user.setRoles(roleList.stream().distinct().toArray(n -> new String[n]));
563 
564         if (logger.isDebugEnabled()) {
565             logger.debug("[updateMemberOf] Initial groups/roles set for user: {}. Groups: {}, Roles: {}", user.getName(),
566                     Arrays.toString(user.getGroupNames()), Arrays.toString(user.getRoleNames()));
567         }
568 
569         // Schedule lazy loading of parent groups
570         if (!groupIdsForParentLookup.isEmpty()) {
571             if (logger.isDebugEnabled()) {
572                 logger.debug("[updateMemberOf] Scheduling parent group lookup for {} group IDs: {}", groupIdsForParentLookup.size(),
573                         groupIdsForParentLookup);
574             }
575             scheduleParentGroupLookup(user, new ArrayList<>(groupList), new ArrayList<>(roleList), groupIdsForParentLookup);
576         } else {
577             if (logger.isDebugEnabled()) {
578                 logger.debug("[updateMemberOf] No parent group lookup needed (no group IDs to process)");
579             }
580         }
581 
582         if (logger.isDebugEnabled()) {
583             logger.debug("[updateMemberOf] Completed for user: {}", user.getName());
584         }
585     }
586 
587     /**
588      * Processes member-of information from Microsoft Graph API.
589      * @param user The Entra ID user.
590      * @param groupList The list to add group names to.
591      * @param roleList The list to add role names to.
592      * @param url The Microsoft Graph API URL.
593      */
594     protected void processMemberOf(final EntraIdUser user, final List<String> groupList, final List<String> roleList, final String url) {
595         if (logger.isDebugEnabled()) {
596             logger.debug("url={}", url);
597         }
598         try (CurlResponse response = Curl.get(url)
599                 .header("Authorization", "Bearer " + user.getAuthenticationResult().accessToken())
600                 .header("Accept", "application/json")
601                 .execute()) {
602             final Map<String, Object> contentMap = response.getContent(OpenSearchCurl.jsonParser());
603             if (logger.isDebugEnabled()) {
604                 logger.debug("response={}", contentMap);
605             }
606             if (contentMap.containsKey("value")) {
607                 @SuppressWarnings("unchecked")
608                 final List<Map<String, Object>> memberOfList = (List<Map<String, Object>>) contentMap.get("value");
609                 final FessConfig fessConfig = ComponentUtil.getFessConfig();
610                 for (final Map<String, Object> memberOf : memberOfList) {
611                     if (logger.isDebugEnabled()) {
612                         logger.debug("member={}", memberOf);
613                     }
614                     String memberType = (String) memberOf.get("@odata.type");
615                     if (memberType == null) {
616                         logger.warn("@odata.type is null: {}", memberOf);
617                         continue;
618                     }
619                     memberType = memberType.toLowerCase(Locale.ENGLISH);
620                     final String id = (String) memberOf.get("id");
621                     if (StringUtil.isNotBlank(id)) {
622                         if (memberType.contains("group")) {
623                             groupList.add(id);
624                         } else if (memberType.contains("role")) {
625                             roleList.add(id);
626                         } else {
627                             if (logger.isDebugEnabled()) {
628                                 logger.debug("Unknown @odata.type: {}", memberOf);
629                             }
630                             groupList.add(id);
631                         }
632                         processParentGroup(user, groupList, roleList, id);
633                     } else {
634                         logger.warn("id is empty: {}", memberOf);
635                     }
636                     final String[] names = fessConfig.getEntraIdPermissionFields();
637                     final boolean useDomainServices = fessConfig.isEntraIdUseDomainServices();
638                     for (final String name : names) {
639                         final String value = (String) memberOf.get(name);
640                         if (StringUtil.isNotBlank(value)) {
641                             if (logger.isDebugEnabled()) {
642                                 logger.debug("{} is a member of {}", name, value);
643                             }
644                             if (memberType.contains("group")) {
645                                 addGroupOrRoleName(groupList, value, useDomainServices);
646                             } else if (memberType.contains("role")) {
647                                 addGroupOrRoleName(roleList, value, useDomainServices);
648                             } else {
649                                 if (logger.isDebugEnabled()) {
650                                     logger.debug("Unknown @odata.type: {}", memberOf);
651                                 }
652                                 addGroupOrRoleName(groupList, value, useDomainServices);
653                             }
654                         } else if (logger.isDebugEnabled()) {
655                             logger.debug("{} is empty: {}", name, memberOf);
656                         }
657                     }
658                 }
659                 final String nextLink = (String) contentMap.get("@odata.nextLink");
660                 if (StringUtil.isNotBlank(nextLink)) {
661                     processMemberOf(user, groupList, roleList, nextLink);
662                 }
663             } else if (contentMap.containsKey("error")) {
664                 logger.warn("Failed to access groups/roles: {}", contentMap);
665             }
666         } catch (final IOException e) {
667             logger.warn("Failed to access groups/roles in Entra ID.", e);
668         }
669     }
670 
671     /**
672      * Adds a group or role name to the specified list.
673      * @param list The list to add the group or role name to.
674      * @param value The group or role name value.
675      * @param useDomainServices Whether to use domain services for group resolution.
676      */
677     protected void addGroupOrRoleName(final List<String> list, final String value, final boolean useDomainServices) {
678         list.add(value);
679         if (useDomainServices && value.indexOf('@') >= 0) {
680             final String[] values = value.split("@");
681             if (values.length > 1) {
682                 list.add(values[0]);
683             }
684         }
685     }
686 
687     /**
688      * Processes direct member-of information from Microsoft Graph API without parent group lookup.
689      * This method retrieves only direct group memberships and collects group IDs for later
690      * asynchronous parent group lookup.
691      * @param user The Entra ID user.
692      * @param groupList The list to add group names to.
693      * @param roleList The list to add role names to.
694      * @param groupIdsForParentLookup The list to collect group IDs for later parent lookup.
695      * @param url The Microsoft Graph API URL.
696      */
697     protected void processDirectMemberOf(final EntraIdUser user, final List<String> groupList, final List<String> roleList,
698             final List<String> groupIdsForParentLookup, final String url) {
699         if (logger.isDebugEnabled()) {
700             logger.debug("[processDirectMemberOf] Fetching direct memberships from URL: {}", url);
701         }
702         try (CurlResponse response = Curl.get(url)
703                 .header("Authorization", "Bearer " + user.getAuthenticationResult().accessToken())
704                 .header("Accept", "application/json")
705                 .execute()) {
706             final Map<String, Object> contentMap = response.getContent(OpenSearchCurl.jsonParser());
707             if (logger.isDebugEnabled()) {
708                 logger.debug("response={}", contentMap);
709             }
710             if (contentMap.containsKey("value")) {
711                 @SuppressWarnings("unchecked")
712                 final List<Map<String, Object>> memberOfList = (List<Map<String, Object>>) contentMap.get("value");
713                 final FessConfig fessConfig = ComponentUtil.getFessConfig();
714                 for (final Map<String, Object> memberOf : memberOfList) {
715                     if (logger.isDebugEnabled()) {
716                         logger.debug("member={}", memberOf);
717                     }
718                     String memberType = (String) memberOf.get("@odata.type");
719                     if (memberType == null) {
720                         logger.warn("@odata.type is null: {}", memberOf);
721                         continue;
722                     }
723                     memberType = memberType.toLowerCase(Locale.ENGLISH);
724                     final String id = (String) memberOf.get("id");
725                     if (StringUtil.isNotBlank(id)) {
726                         if (memberType.contains("group")) {
727                             groupList.add(id);
728                             // Collect group ID for parent lookup (deferred)
729                             groupIdsForParentLookup.add(id);
730                             if (logger.isDebugEnabled()) {
731                                 logger.debug("[processDirectMemberOf] Added group ID: {} (will lookup parent groups later)", id);
732                             }
733                         } else if (memberType.contains("role")) {
734                             roleList.add(id);
735                             if (logger.isDebugEnabled()) {
736                                 logger.debug("[processDirectMemberOf] Added role ID: {}", id);
737                             }
738                         } else {
739                             if (logger.isDebugEnabled()) {
740                                 logger.debug("[processDirectMemberOf] Unknown @odata.type: {}, treating as group", memberOf);
741                             }
742                             groupList.add(id);
743                             groupIdsForParentLookup.add(id);
744                         }
745                     } else {
746                         logger.warn("id is empty: {}", memberOf);
747                     }
748                     final String[] names = fessConfig.getEntraIdPermissionFields();
749                     final boolean useDomainServices = fessConfig.isEntraIdUseDomainServices();
750                     for (final String name : names) {
751                         final String value = (String) memberOf.get(name);
752                         if (StringUtil.isNotBlank(value)) {
753                             if (logger.isDebugEnabled()) {
754                                 logger.debug("{} is a member of {}", name, value);
755                             }
756                             if (memberType.contains("group")) {
757                                 addGroupOrRoleName(groupList, value, useDomainServices);
758                             } else if (memberType.contains("role")) {
759                                 addGroupOrRoleName(roleList, value, useDomainServices);
760                             } else {
761                                 addGroupOrRoleName(groupList, value, useDomainServices);
762                             }
763                         } else if (logger.isDebugEnabled()) {
764                             logger.debug("{} is empty: {}", name, memberOf);
765                         }
766                     }
767                 }
768                 final String nextLink = (String) contentMap.get("@odata.nextLink");
769                 if (StringUtil.isNotBlank(nextLink)) {
770                     processDirectMemberOf(user, groupList, roleList, groupIdsForParentLookup, nextLink);
771                 }
772             } else if (contentMap.containsKey("error")) {
773                 logger.warn("Failed to access groups/roles: {}", contentMap);
774             }
775         } catch (final IOException e) {
776             logger.warn("Failed to access groups/roles in Entra ID.", e);
777         }
778     }
779 
780     /**
781      * Schedules asynchronous parent group lookup using TimeoutManager.
782      * This method defers the retrieval of nested group information to avoid login delays.
783      * @param user The Entra ID user.
784      * @param initialGroups The initial group list to be updated.
785      * @param initialRoles The initial role list to be updated.
786      * @param groupIds The list of group IDs to lookup parent groups for.
787      */
788     protected void scheduleParentGroupLookup(final EntraIdUser user, final List<String> initialGroups, final List<String> initialRoles,
789             final List<String> groupIds) {
790         if (logger.isDebugEnabled()) {
791             logger.debug("[scheduleParentGroupLookup] Scheduling async parent group lookup for user: {}, groupIds count: {}",
792                     user.getName(), groupIds.size());
793         }
794         TimeoutManager.getInstance().addTimeoutTarget(() -> {
795             if (logger.isDebugEnabled()) {
796                 logger.debug("[scheduleParentGroupLookup] Async task started for user: {}", user.getName());
797             }
798             final long startTime = System.currentTimeMillis();
799             try {
800                 final List<String> updatedGroups = new ArrayList<>(initialGroups);
801                 final List<String> updatedRoles = new ArrayList<>(initialRoles);
802 
803                 if (logger.isDebugEnabled()) {
804                     logger.debug("[scheduleParentGroupLookup] Processing {} group IDs for parent lookup", groupIds.size());
805                 }
806 
807                 int processedCount = 0;
808                 for (final String groupId : groupIds) {
809                     if (logger.isDebugEnabled()) {
810                         logger.debug("[scheduleParentGroupLookup] Processing parent groups for groupId: {} ({}/{})", groupId,
811                                 ++processedCount, groupIds.size());
812                     }
813                     processParentGroup(user, updatedGroups, updatedRoles, groupId);
814                 }
815 
816                 // Update groups/roles
817                 final String[] finalGroups = updatedGroups.stream().distinct().toArray(n -> new String[n]);
818                 final String[] finalRoles = updatedRoles.stream().distinct().toArray(n -> new String[n]);
819                 user.setGroups(finalGroups);
820                 user.setRoles(finalRoles);
821 
822                 // Reset permissions to force recalculation
823                 user.resetPermissions();
824 
825                 final long elapsedTime = System.currentTimeMillis() - startTime;
826                 if (logger.isDebugEnabled()) {
827                     logger.debug(
828                             "[scheduleParentGroupLookup] Async task completed for user: {}. Final groups: {}, Final roles: {}, Elapsed time: {}ms",
829                             user.getName(), finalGroups.length, finalRoles.length, elapsedTime);
830                     logger.debug("[scheduleParentGroupLookup] Final groups for user {}: {}", user.getName(), Arrays.toString(finalGroups));
831                     logger.debug("[scheduleParentGroupLookup] Final roles for user {}: {}", user.getName(), Arrays.toString(finalRoles));
832                 }
833 
834                 // Update session information
835                 if (logger.isDebugEnabled()) {
836                     logger.debug("[scheduleParentGroupLookup] Notifying permission change for user: {}", user.getName());
837                 }
838                 ComponentUtil.getActivityHelper().permissionChanged(OptionalThing.of(new FessUserBean(user)));
839             } catch (final Exception e) {
840                 final long elapsedTime = System.currentTimeMillis() - startTime;
841                 logger.warn("Failed to process parent groups asynchronously for user: {} after {}ms", user.getName(), elapsedTime, e);
842             }
843         }, 0, false);
844     }
845 
846     /**
847      * Processes parent group information for nested groups.
848      * @param user The Entra ID user.
849      * @param groupList The list to add group names to.
850      * @param roleList The list to add role names to.
851      * @param id The group ID to process.
852      */
853     protected void processParentGroup(final EntraIdUser user, final List<String> groupList, final List<String> roleList, final String id) {
854         processParentGroup(user, groupList, roleList, id, 0);
855     }
856 
857     /**
858      * Processes parent group information for nested groups with depth tracking.
859      * @param user The Entra ID user.
860      * @param groupList The list to add group names to.
861      * @param roleList The list to add role names to.
862      * @param id The group ID to process.
863      * @param depth The current recursion depth.
864      */
865     protected void processParentGroup(final EntraIdUser user, final List<String> groupList, final List<String> roleList, final String id,
866             final int depth) {
867         if (logger.isDebugEnabled()) {
868             logger.debug("[processParentGroup] Processing parent groups for id: {}, depth: {}/{}", id, depth, maxGroupDepth);
869         }
870         if (depth >= maxGroupDepth) {
871             if (logger.isDebugEnabled()) {
872                 logger.debug("[processParentGroup] Maximum group depth {} reached for group {}", maxGroupDepth, id);
873             }
874             return;
875         }
876         final Pair<String[], String[]> groupsAndRoles = getParentGroup(user, id, depth);
877         StreamUtil.stream(groupsAndRoles.getFirst()).of(stream -> stream.forEach(groupList::add));
878         StreamUtil.stream(groupsAndRoles.getSecond()).of(stream -> stream.forEach(roleList::add));
879         if (logger.isDebugEnabled()) {
880             logger.debug("[processParentGroup] Completed for id: {}, depth: {}, added groups: {}, added roles: {}", id, depth,
881                     groupsAndRoles.getFirst().length, groupsAndRoles.getSecond().length);
882         }
883     }
884 
885     /**
886      * Retrieves parent group information for the specified group ID.
887      * @param user The Entra ID user.
888      * @param id The group ID to get parent information for.
889      * @return A pair containing group names and role names.
890      */
891     protected Pair<String[], String[]> getParentGroup(final EntraIdUser user, final String id) {
892         return getParentGroup(user, id, 0);
893     }
894 
895     /**
896      * Retrieves parent group information for the specified group ID with depth tracking.
897      * @param user The Entra ID user.
898      * @param id The group ID to get parent information for.
899      * @param depth The current recursion depth.
900      * @return A pair containing group names and role names.
901      */
902     protected Pair<String[], String[]> getParentGroup(final EntraIdUser user, final String id, final int depth) {
903         if (logger.isDebugEnabled()) {
904             logger.debug("[getParentGroup] Getting parent groups for id: {}, depth: {}", id, depth);
905         }
906         if (depth >= maxGroupDepth) {
907             if (logger.isDebugEnabled()) {
908                 logger.debug("[getParentGroup] Maximum group depth {} reached for group {}", maxGroupDepth, id);
909             }
910             return new Pair<>(StringUtil.EMPTY_STRINGS, StringUtil.EMPTY_STRINGS);
911         }
912         // Check if cached
913         final Pair<String[], String[]> cachedResult = groupCache.getIfPresent(id);
914         if (cachedResult != null) {
915             if (logger.isDebugEnabled()) {
916                 logger.debug("[getParentGroup] Cache HIT for id: {}, groups: {}, roles: {}", id, cachedResult.getFirst().length,
917                         cachedResult.getSecond().length);
918             }
919             return cachedResult;
920         }
921         if (logger.isDebugEnabled()) {
922             logger.debug("[getParentGroup] Cache MISS for id: {}, fetching from API", id);
923         }
924         try {
925             return groupCache.get(id, () -> {
926                 if (logger.isDebugEnabled()) {
927                     logger.debug("[getParentGroup] Loading parent groups for id: {} into cache", id);
928                 }
929                 final List<String> groupList = new ArrayList<>();
930                 final List<String> roleList = new ArrayList<>();
931                 final String url = "https://graph.microsoft.com/v1.0/groups/" + id + "/getMemberGroups";
932                 if (logger.isDebugEnabled()) {
933                     logger.debug("[getParentGroup] Calling API: {}", url);
934                 }
935                 try (CurlResponse response = Curl.post(url)
936                         .header("Authorization", "Bearer " + user.getAuthenticationResult().accessToken())
937                         .header("Accept", "application/json")
938                         .header("Content-type", "application/json")
939                         .body("{\"securityEnabledOnly\":false}")
940                         .execute()) {
941                     final Map<String, Object> contentMap = response.getContent(OpenSearchCurl.jsonParser());
942                     if (logger.isDebugEnabled()) {
943                         logger.debug("[getParentGroup] Response for id {}: {}", id, contentMap);
944                     }
945                     if (contentMap.containsKey("value")) {
946                         final String[] values = DocumentUtil.getValue(contentMap, "value", String[].class);
947                         if (values != null) {
948                             if (logger.isDebugEnabled()) {
949                                 logger.debug("[getParentGroup] Found {} parent group IDs for id: {}", values.length, id);
950                             }
951                             for (final String value : values) {
952                                 if (logger.isDebugEnabled()) {
953                                     logger.debug("[getParentGroup] Processing parent group id: {} for group: {}", value, id);
954                                 }
955                                 processGroup(user, groupList, roleList, value);
956                                 if (!groupList.contains(value) && !roleList.contains(value)) {
957                                     if (logger.isDebugEnabled()) {
958                                         logger.debug("[getParentGroup] Recursively getting parent groups for: {}", value);
959                                     }
960                                     final Pair<String[], String[]> groupsAndRoles = getParentGroup(user, value, depth + 1);
961                                     StreamUtil.stream(groupsAndRoles.getFirst()).of(stream1 -> stream1.forEach(groupList::add));
962                                     StreamUtil.stream(groupsAndRoles.getSecond()).of(stream2 -> stream2.forEach(roleList::add));
963                                 }
964                             }
965                         }
966                     } else if (contentMap.containsKey("error")) {
967                         @SuppressWarnings("unchecked")
968                         final Map<String, Object> errorMap = (Map<String, Object>) contentMap.get("error");
969                         if ("Request_ResourceNotFound".equals(errorMap.get("code"))) {
970                             if (logger.isDebugEnabled()) {
971                                 logger.debug("[getParentGroup] Resource not found for id {}: {}", id, contentMap);
972                             }
973                         } else {
974                             logger.warn("Failed to access parent groups for id {}: {}", id, contentMap);
975                         }
976                     }
977                 } catch (final IOException e) {
978                     logger.warn("Failed to access groups/roles in Entra ID for id: {}", id, e);
979                 }
980                 final Pair<String[], String[]> result = new Pair<>(groupList.stream().distinct().toArray(n1 -> new String[n1]),
981                         roleList.stream().distinct().toArray(n2 -> new String[n2]));
982                 if (logger.isDebugEnabled()) {
983                     logger.debug("[getParentGroup] Cached result for id {}: {} groups, {} roles", id, result.getFirst().length,
984                             result.getSecond().length);
985                 }
986                 return result;
987             });
988         } catch (final ExecutionException e) {
989             logger.warn("Failed to process group cache for id: {}", id, e);
990             return new Pair<>(StringUtil.EMPTY_STRINGS, StringUtil.EMPTY_STRINGS);
991         }
992     }
993 
994     /**
995      * Processes individual group information.
996      * @param user The Entra ID user.
997      * @param groupList The list to add group names to.
998      * @param roleList The list to add role names to.
999      * @param id The group ID to process.
1000      */
1001     protected void processGroup(final EntraIdUser user, final List<String> groupList, final List<String> roleList, final String id) {
1002         if (logger.isDebugEnabled()) {
1003             logger.debug("[processGroup] Processing group info for id: {}", id);
1004         }
1005         final String url = "https://graph.microsoft.com/v1.0/groups/" + id;
1006         if (logger.isDebugEnabled()) {
1007             logger.debug("[processGroup] Fetching from url: {}", url);
1008         }
1009         try (CurlResponse response = Curl.get(url)
1010                 .header("Authorization", "Bearer " + user.getAuthenticationResult().accessToken())
1011                 .header("Accept", "application/json")
1012                 .execute()) {
1013             final Map<String, Object> contentMap = response.getContent(OpenSearchCurl.jsonParser());
1014             if (logger.isDebugEnabled()) {
1015                 logger.debug("[processGroup] Response for id {}: {}", id, contentMap);
1016             }
1017             groupList.add(id);
1018             if (contentMap.containsKey("error")) {
1019                 logger.warn("Failed to access group info: {}", contentMap);
1020             } else {
1021                 final FessConfig fessConfig = ComponentUtil.getFessConfig();
1022                 final String[] names = fessConfig.getEntraIdPermissionFields();
1023                 final int initialSize = groupList.size();
1024                 for (final String name : names) {
1025                     final String value = (String) contentMap.get(name);
1026                     if (StringUtil.isNotBlank(value)) {
1027                         groupList.add(value);
1028                         if (logger.isDebugEnabled()) {
1029                             logger.debug("[processGroup] Added {} value: {} for group id: {}", name, value, id);
1030                         }
1031                     } else if (logger.isDebugEnabled()) {
1032                         logger.debug("[processGroup] {} is empty for group id: {}", name, id);
1033                     }
1034                 }
1035                 if (logger.isDebugEnabled()) {
1036                     logger.debug("[processGroup] Completed for id: {}, added {} entries", id, groupList.size() - initialSize);
1037                 }
1038             }
1039         } catch (final IOException e) {
1040             logger.warn("Failed to access groups/roles in Entra ID for id: {}", id, e);
1041         }
1042     }
1043 
1044     /**
1045      * Gets the default group list for users.
1046      * Uses new entraid.default.groups key with fallback to legacy aad.default.groups.
1047      * @return The default group list.
1048      */
1049     protected List<String> getDefaultGroupList() {
1050         String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_DEFAULT_GROUPS);
1051         if (StringUtil.isBlank(value)) {
1052             value = ComponentUtil.getFessConfig().getSystemProperty(AAD_DEFAULT_GROUPS);
1053         }
1054         if (StringUtil.isBlank(value)) {
1055             return Collections.emptyList();
1056         }
1057         return split(value, ",").get(stream -> stream.filter(StringUtil::isNotBlank).map(String::trim).collect(Collectors.toList()));
1058     }
1059 
1060     /**
1061      * Gets the default role list for users.
1062      * Uses new entraid.default.roles key with fallback to legacy aad.default.roles.
1063      * @return The default role list.
1064      */
1065     protected List<String> getDefaultRoleList() {
1066         String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_DEFAULT_ROLES);
1067         if (StringUtil.isBlank(value)) {
1068             value = ComponentUtil.getFessConfig().getSystemProperty(AAD_DEFAULT_ROLES);
1069         }
1070         if (StringUtil.isBlank(value)) {
1071             return Collections.emptyList();
1072         }
1073         return split(value, ",").get(stream -> stream.filter(StringUtil::isNotBlank).map(String::trim).collect(Collectors.toList()));
1074     }
1075 
1076     /**
1077      * Represents state data stored during the OAuth2 authentication flow.
1078      */
1079     protected static class StateData {
1080         private final String nonce;
1081         private final long expiration;
1082 
1083         /**
1084          * Constructs StateData with nonce and expiration.
1085          * @param nonce The nonce value.
1086          * @param expiration The expiration timestamp.
1087          */
1088         public StateData(final String nonce, final long expiration) {
1089             this.nonce = nonce;
1090             this.expiration = expiration;
1091         }
1092 
1093         /**
1094          * Gets the nonce value.
1095          * @return The nonce.
1096          */
1097         public String getNonce() {
1098             return nonce;
1099         }
1100 
1101         /**
1102          * Gets the expiration timestamp.
1103          * @return The expiration timestamp.
1104          */
1105         public long getExpiration() {
1106             return expiration;
1107         }
1108 
1109         @Override
1110         public String toString() {
1111             return "StateData [nonce=" + nonce + ", expiration=" + expiration + "]";
1112         }
1113     }
1114 
1115     /**
1116      * Gets the Entra ID client ID from configuration.
1117      * Uses new entraid.client.id key with fallback to legacy aad.client.id.
1118      * @return The client ID.
1119      */
1120     protected String getClientId() {
1121         String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_CLIENT_ID);
1122         if (StringUtil.isBlank(value)) {
1123             value = ComponentUtil.getFessConfig().getSystemProperty(AAD_CLIENT_ID, StringUtil.EMPTY);
1124         }
1125         return value;
1126     }
1127 
1128     /**
1129      * Gets the Entra ID client secret from configuration.
1130      * Uses new entraid.client.secret key with fallback to legacy aad.client.secret.
1131      * @return The client secret.
1132      */
1133     protected String getClientSecret() {
1134         String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_CLIENT_SECRET);
1135         if (StringUtil.isBlank(value)) {
1136             value = ComponentUtil.getFessConfig().getSystemProperty(AAD_CLIENT_SECRET, StringUtil.EMPTY);
1137         }
1138         return value;
1139     }
1140 
1141     /**
1142      * Gets the Entra ID tenant ID from configuration.
1143      * Uses new entraid.tenant key with fallback to legacy aad.tenant.
1144      * @return The tenant ID.
1145      */
1146     protected String getTenant() {
1147         String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_TENANT);
1148         if (StringUtil.isBlank(value)) {
1149             value = ComponentUtil.getFessConfig().getSystemProperty(AAD_TENANT, StringUtil.EMPTY);
1150         }
1151         return value;
1152     }
1153 
1154     /**
1155      * Gets the Entra ID authority URL from configuration.
1156      * Uses new entraid.authority key with fallback to legacy aad.authority.
1157      * @return The authority URL.
1158      */
1159     protected String getAuthority() {
1160         String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_AUTHORITY);
1161         if (StringUtil.isBlank(value)) {
1162             value = ComponentUtil.getFessConfig().getSystemProperty(AAD_AUTHORITY, "https://login.microsoftonline.com/");
1163         }
1164         return value;
1165     }
1166 
1167     /**
1168      * Gets the state time-to-live from configuration.
1169      * Uses new entraid.state.ttl key with fallback to legacy aad.state.ttl.
1170      * @return The state TTL in milliseconds.
1171      */
1172     protected long getStateTtl() {
1173         String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_STATE_TTL);
1174         if (StringUtil.isBlank(value)) {
1175             value = ComponentUtil.getFessConfig().getSystemProperty(AAD_STATE_TTL, "3600");
1176         }
1177         return Long.parseLong(value);
1178     }
1179 
1180     /**
1181      * Gets the reply URL for Entra ID authentication.
1182      * Uses new entraid.reply.url key with fallback to legacy aad.reply.url.
1183      * @param request The HTTP servlet request.
1184      * @return The reply URL.
1185      */
1186     protected String getReplyUrl(final HttpServletRequest request) {
1187         String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_REPLY_URL);
1188         if (StringUtil.isBlank(value)) {
1189             value = ComponentUtil.getFessConfig().getSystemProperty(AAD_REPLY_URL, StringUtil.EMPTY);
1190         }
1191         if (StringUtil.isNotBlank(value)) {
1192             return value;
1193         }
1194         return request.getRequestURL().toString();
1195     }
1196 
1197     @Override
1198     public void resolveCredential(final LoginCredentialResolver resolver) {
1199         resolver.resolve(EntraIdCredential.class, credential -> OptionalEntity.of(credential.getUser()));
1200     }
1201 
1202     /**
1203      * Sets the token acquisition timeout.
1204      * @param acquisitionTimeout The timeout in milliseconds.
1205      */
1206     public void setAcquisitionTimeout(final long acquisitionTimeout) {
1207         this.acquisitionTimeout = acquisitionTimeout;
1208     }
1209 
1210     /**
1211      * Sets the group cache expiry time.
1212      * @param groupCacheExpiry The cache expiry time in seconds.
1213      */
1214     public void setGroupCacheExpiry(final long groupCacheExpiry) {
1215         this.groupCacheExpiry = groupCacheExpiry;
1216     }
1217 
1218     /**
1219      * Sets the maximum group depth for nested group processing.
1220      * @param maxGroupDepth The maximum depth for nested groups.
1221      */
1222     public void setMaxGroupDepth(final int maxGroupDepth) {
1223         this.maxGroupDepth = maxGroupDepth;
1224     }
1225 
1226     @Override
1227     public ActionResponse getResponse(final SsoResponseType responseType) {
1228         return null;
1229     }
1230 
1231     @Override
1232     public String logout(final FessUserBean user) {
1233         return null;
1234     }
1235 
1236     /**
1237      * Enable to use V2 endpoint.
1238      * @param useV2Endpoint true if using V2 endpoint.
1239      */
1240     public void setUseV2Endpoint(final boolean useV2Endpoint) {
1241         this.useV2Endpoint = useV2Endpoint;
1242     }
1243 }