org.bouncycastle
bcprov-jdk18on
diff --git a/src/main/java/org/codelibs/fess/app/web/admin/systeminfo/AdminSysteminfoAction.java b/src/main/java/org/codelibs/fess/app/web/admin/systeminfo/AdminSysteminfoAction.java
index 3d4568ae6..bc34bc9be 100644
--- a/src/main/java/org/codelibs/fess/app/web/admin/systeminfo/AdminSysteminfoAction.java
+++ b/src/main/java/org/codelibs/fess/app/web/admin/systeminfo/AdminSysteminfoAction.java
@@ -241,8 +241,8 @@ protected static boolean isPrivateKeyMaterial(final String key) {
* Only OpenID Connect was listed by name before, so the Entra ID client secret was rendered
* in cleartext under System Info > Config Info, and was also copied verbatim into the bug
* report that users paste into public issues. The legacy {@code aad.*} keys are covered by the
- * same shape because {@link org.codelibs.fess.sso.entraid.EntraIdAuthenticator} still reads
- * them as a fallback.
+ * same shape because {@code EntraIdAuthenticator}, now in the fess-sso-entraid plugin,
+ * still reads them as a fallback.
*
* @param key the property key to check
* @return true if the key matches the SSO client credential shape
diff --git a/src/main/java/org/codelibs/fess/app/web/base/login/EntraIdCredential.java b/src/main/java/org/codelibs/fess/app/web/base/login/EntraIdCredential.java
deleted file mode 100644
index e74cc4496..000000000
--- a/src/main/java/org/codelibs/fess/app/web/base/login/EntraIdCredential.java
+++ /dev/null
@@ -1,437 +0,0 @@
-/*
- * Copyright 2012-2025 CodeLibs Project and the Others.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
- * either express or implied. See the License for the specific language
- * governing permissions and limitations under the License.
- */
-package org.codelibs.fess.app.web.base.login;
-
-import static org.codelibs.core.stream.StreamUtil.stream;
-
-import java.util.HashSet;
-import java.util.Set;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
-import org.codelibs.core.lang.StringUtil;
-import org.codelibs.fess.entity.FessUser;
-import org.codelibs.fess.helper.SystemHelper;
-import org.codelibs.fess.sso.entraid.EntraIdAuthenticator;
-import org.codelibs.fess.util.ComponentUtil;
-import org.lastaflute.web.login.credential.LoginCredential;
-
-import com.microsoft.aad.msal4j.IAccount;
-import com.microsoft.aad.msal4j.IAuthenticationResult;
-import com.nimbusds.jwt.JWTClaimsSet;
-import com.nimbusds.jwt.JWTParser;
-
-/**
- * Microsoft Entra ID credential implementation for Fess authentication.
- * Provides login credential functionality using Entra ID authentication results.
- */
-public class EntraIdCredential implements LoginCredential, FessCredential {
-
- private static final Logger logger = LogManager.getLogger(EntraIdCredential.class);
-
- private final IAuthenticationResult authResult;
-
- /**
- * Constructs an Entra ID credential with the authentication result.
- * @param authResult The authentication result from Entra ID.
- */
- public EntraIdCredential(final IAuthenticationResult authResult) {
- this.authResult = authResult;
- }
-
- @Override
- public String getUserId() {
- return authResult.account().username();
- }
-
- @Override
- public String toString() {
- return "{" + authResult.account().username() + "}";
- }
-
- /**
- * Gets the Entra ID user associated with this credential.
- * @return The Entra ID user instance.
- */
- public EntraIdUser getUser() {
- return new EntraIdUser(authResult);
- }
-
- /**
- * Entra ID user implementation providing user information and permissions.
- */
- public static class EntraIdUser implements FessUser {
- private static final long serialVersionUID = 1L;
-
- /**
- * How long before the access token expires {@link #refresh()} starts asking MSAL4J for a
- * new one. It matches MSAL4J's own expiry buffer, so the token is renewed at the same
- * instant it always was; what the guard removes is the silent acquisition -- and the
- * Microsoft Graph call behind it -- on every other request.
- */
- protected static final long REFRESH_MARGIN = 5 * 60 * 1000L;
-
- /**
- * How long a silent acquisition that failed is left alone before another one is attempted
- * for this session. A refresh token that has been revoked, an account that has been
- * disabled, and an account a {@code logout()} elsewhere evicted from the shared MSAL4J
- * cache all fail for good, and {@link #refresh()} runs on every action request, so
- * retrying one unconditionally would put back the per-request round trip
- * {@link #REFRESH_MARGIN} was introduced to remove. A minute matches the backoff
- * {@code EntraIdAuthenticator} applies to a throttled Microsoft Graph, holds a session
- * whose renewal cannot succeed to one acquisition a minute rather than one per request,
- * and is short enough that a failure early in {@link #REFRESH_MARGIN} still leaves four
- * more attempts before the token actually expires.
- */
- protected static final long RENEWAL_THROTTLE_INTERVAL = 60 * 1000L;
-
- /** User's group memberships. */
- protected volatile String[] groups;
-
- /** User's role assignments. */
- protected volatile String[] roles;
-
- /** User's computed permissions. */
- protected volatile String[] permissions;
-
- /**
- * Entra ID authentication result. Volatile because {@link #refresh()} replaces it from
- * whichever request thread wins the renewal while the other request threads sharing this
- * session-scoped instance keep reading it.
- */
- protected volatile IAuthenticationResult authResult;
-
- /**
- * How far this user's group and role permissions have got. Volatile because the resolution
- * runs on a TimeoutManager thread while request threads read it.
- *
- *
Starts PENDING: unlike every other {@code FessUser}, this one is handed out before its
- * memberships exist.
- */
- protected volatile PermissionState permissionState = PermissionState.PENDING;
-
- /**
- * Whether a membership resolution has ever run to completion for this user -- whether it
- * reached Microsoft Graph or fell back to the configured defaults. Volatile for the same
- * reason as {@link #permissionState}: written on a TimeoutManager thread, read on request
- * threads.
- *
- *
An explicit flag rather than {@code groups == null}, which is what it used to be
- * inferred from: the constructor now seeds the configured defaults, so the memberships are
- * never null and every resolution would look like a re-resolution -- keeping the seeded
- * defaults forever instead of writing the resolved groups.
- */
- protected volatile boolean resolutionCompleted;
-
- /**
- * Set for as long as one thread is inside the silent acquisition in {@link #refresh()}.
- * A plain flag rather than a lock: the losing threads must carry on with the token they
- * already hold instead of queueing behind an acquisition that can take tens of seconds.
- */
- private final AtomicBoolean refreshing = new AtomicBoolean();
-
- /**
- * Point in time, as epoch milliseconds, until which {@link #refresh()} attempts no further
- * silent acquisition. Zero means none has failed yet. Written by whichever request thread
- * ran the failing acquisition while the other request threads sharing this session-scoped
- * instance keep reading it, hence volatile.
- */
- protected volatile long renewalThrottledUntil;
-
- /**
- * Constructs an Entra ID user with the authentication result.
- * @param authResult The authentication result from Entra ID.
- */
- public EntraIdUser(final IAuthenticationResult authResult) {
- this.authResult = authResult;
- final EntraIdAuthenticator authenticator = ComponentUtil.getComponent(EntraIdAuthenticator.class);
- // The configured defaults are static -- no Graph call stands behind them -- so they
- // apply from the first request rather than only once the background resolution lands.
- // SsoAction redirects straight to the search page after login, so without this the
- // first results a user sees are those of someone holding no groups at all.
- authenticator.applyDefaultMemberships(this);
- authenticator.scheduleUpdateMemberOf(this);
- }
-
- @Override
- public String getName() {
- return authResult.account().username();
- }
-
- @Override
- public String[] getRoleNames() {
- return roles;
- }
-
- @Override
- public String[] getGroupNames() {
- return groups;
- }
-
- @Override
- public synchronized String[] getPermissions() {
- // Synchronized on the same monitor as setGroups/setRoles/resetPermissions. Computing
- // the value is a check-then-act -- read `permissions == null`, read `groups`, write
- // `permissions` -- and the membership resolution scheduled at login runs on a
- // TimeoutManager thread while the user is already searching. Without the lock a reader
- // that started before it lands can finish after and overwrite the freshly reset value
- // with one computed from stale (or absent) groups; nothing resets it again, so those
- // permissions stay wrong for the rest of the session.
- if (permissions == null) {
- final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
- final Set permissionSet = new HashSet<>();
- final IAccount account = authResult.account();
- final String objectId = getObjectId();
- final String username = account.username();
- if (logger.isDebugEnabled()) {
- logger.debug("objectId={}, username={}", objectId, username);
- }
- if (StringUtil.isNotBlank(objectId)) {
- permissionSet.add(systemHelper.getSearchRoleByDirectoryUser(objectId));
- }
- permissionSet.add(systemHelper.getSearchRoleByDirectoryUser(username));
- if (ComponentUtil.getFessConfig().isEntraIdUseDomainServices() && username.indexOf('@') >= 0) {
- final String[] values = username.split("@");
- if (values.length > 1) {
- permissionSet.add(systemHelper.getSearchRoleByDirectoryUser(values[0]));
- }
- }
- stream(groups).of(stream -> stream.forEach(s -> permissionSet.add(systemHelper.getSearchRoleByDirectoryGroup(s))));
- stream(roles).of(stream -> stream.forEach(s -> permissionSet.add(systemHelper.getSearchRoleByDirectoryRole(s))));
- permissions = permissionSet.stream().filter(StringUtil::isNotBlank).distinct().toArray(n -> new String[n]);
- }
- return permissions;
- }
-
- /**
- * Reads the {@code oid} claim -- the user's object id in this tenant -- out of the ID
- * token.
- *
- * Microsoft Graph names a user by that object id, so it is the value a crawler writes
- * into the {@code role} field of a document this user owns. {@code IAccount} exposes no
- * plain object id of its own: {@code homeAccountId()} is MSAL4J's own account key, and
- * {@code getTenantProfiles()} is null on the account an {@code IAuthenticationResult}
- * carries.
- *
- * @return The object id, or null when the ID token carries none.
- */
- protected String getObjectId() {
- final String idToken = authResult.idToken();
- if (StringUtil.isBlank(idToken)) {
- logger.warn("No ID token for {}. The object id permission is not granted.", getName());
- return null;
- }
- try {
- final JWTClaimsSet claimsSet = JWTParser.parse(idToken).getJWTClaimsSet();
- if (claimsSet != null) {
- return claimsSet.getStringClaim("oid");
- }
- logger.warn("The ID token of {} carries no claims. The object id permission is not granted.", getName());
- } catch (final Exception e) {
- logger.warn("Failed to read the oid claim of {}. The object id permission is not granted.", getName(), e);
- }
- return null;
- }
-
- @Override
- public boolean refresh() {
- // MSAL4J handles token refresh internally through silent authentication
- // Check if token is still valid by comparing absolute timestamps
- final long tokenExpiryTime = authResult.expiresOnDate().getTime(); // milliseconds since epoch
- final long currentTime = ComponentUtil.getSystemHelper().getCurrentTimeAsLong(); // milliseconds since epoch
- final boolean expired = tokenExpiryTime < currentTime;
- if (!expired && tokenExpiryTime - currentTime > REFRESH_MARGIN) {
- // FessBaseAction.godHandPrologue calls this on every action request; a silent
- // acquisition is a network call, so it must not happen per request. Until the
- // token is close to expiring there is nothing to acquire, and the groups this
- // user was given at login are still the ones Entra ID issued them.
- if (logger.isDebugEnabled()) {
- logger.debug("Token is still valid for {}ms. Skipping silent authentication.", tokenExpiryTime - currentTime);
- }
- return true;
- }
- // An expired access token still goes through the acquisition below rather than
- // straight out of here. MSAL4J's silent flow spends the cached refresh token, which
- // outlives the access token by hours, so a user who was idle across the expiry is
- // recoverable -- and giving up instead was permanent, because godHandPrologue
- // discards this result: nothing logged the user out, and every later request took the
- // same early exit, so the session kept a dead token and stopped re-reading its group
- // memberships for as long as it lasted.
- //
- // Attempting it is not free, though: an acquisition that cannot succeed would be
- // repeated on every request of a session that keeps searching, so one failure holds
- // the next attempt off for RENEWAL_THROTTLE_INTERVAL.
- if (isRenewalThrottled(currentTime)) {
- if (logger.isDebugEnabled()) {
- logger.debug("A silent authentication has just failed. Not retrying before {}. expired={}", renewalThrottledUntil,
- expired);
- }
- return !expired;
- }
- // Lastaflute keeps one FessUserBean -- and therefore one EntraIdUser -- as a session
- // attribute, and FessBaseAction.godHandPrologue calls refresh() on every action
- // request, so all the requests a session has in flight arrive here together once the
- // token enters REFRESH_MARGIN. Each of them would see a renewed token and schedule its
- // own updateMemberOf task, and the last one to assign would decide which of the
- // results authResult ends up holding. One renewal per rollover is enough.
- // Not a lock, and deliberately not a synchronized method: the acquisition runs for up
- // to the authenticator's acquisition timeout, and getPermissions() takes this object's
- // monitor, so waiting here would stall every concurrent search of this user for that
- // whole time.
- if (!refreshing.compareAndSet(false, true)) {
- if (logger.isDebugEnabled()) {
- logger.debug("Another request is already renewing the token. Skipping silent authentication.");
- }
- // A token that has not expired yet lets this request proceed while the winner
- // renews. An expired one does not, and whether the winner recovers it is not this
- // thread's to report.
- return !expired;
- }
- // Attempt to refresh token using MSAL4J silent authentication
- try {
- final EntraIdAuthenticator authenticator = ComponentUtil.getComponent(EntraIdAuthenticator.class);
- final IAuthenticationResult newResult = authenticator.refreshTokenSilently(this);
- if (newResult != null && newResult.expiresOnDate().getTime() >= currentTime) {
- // MSAL4J rounds its own buffer down to whole seconds, so for up to a second
- // either side of REFRESH_MARGIN it hands back the token it already had.
- // Re-reading the directory for a token that did not change would put the
- // per-request Graph call straight back.
- final boolean renewed = !newResult.accessToken().equals(authResult.accessToken());
- authResult = newResult;
- if (renewed) {
- // Scheduled, not called: this runs on a request thread, and updateMemberOf
- // reaches Microsoft Graph. It resets the permissions itself when it lands.
- authenticator.scheduleUpdateMemberOf(this);
- }
- if (logger.isDebugEnabled()) {
- logger.debug("Silent authentication succeeded. renewed={}", renewed);
- }
- return true;
- }
- // refreshTokenSilently answers null instead of throwing, so a revoked refresh
- // token, a disabled account, and an account evicted from the shared MSAL4J cache
- // all arrive here rather than in the catch below. A result that is itself already
- // expired is treated the same way: keeping it would leave nothing to renew from
- // and no record that the renewal has to be held off.
- applyRenewalThrottle(currentTime);
- logger.warn("Silent authentication returned no usable access token for {}. expired={}. Next attempt in {} seconds.",
- getName(), expired, RENEWAL_THROTTLE_INTERVAL / 1000L);
- } catch (final Exception e) {
- // At WARN, not DEBUG: this is the same anti-pattern #3218 removed from
- // getLoginCredential, where a login that failed was invisible unless debug
- // logging happened to be on. The throttle applied first is what keeps a
- // persistent failure to one line per interval instead of one per request.
- applyRenewalThrottle(currentTime);
- logger.warn("Failed to renew the access token of {}. expired={}. Next attempt in {} seconds.", getName(), expired,
- RENEWAL_THROTTLE_INTERVAL / 1000L, e);
- } finally {
- refreshing.set(false);
- }
- // The silent acquisition produced nothing. A token that has not expired yet still
- // authorises this request and MSAL4J is asked again once the throttle lapses, but an
- // expired one leaves nothing to carry on with.
- return !expired;
- }
-
- /**
- * Returns whether a silent acquisition failed recently enough that another one has to wait.
- *
- * @param currentTime The current time in epoch milliseconds.
- * @return True while the silent acquisition has to be skipped.
- */
- protected boolean isRenewalThrottled(final long currentTime) {
- final long until = renewalThrottledUntil;
- return until > 0L && currentTime < until;
- }
-
- /**
- * Records that a silent acquisition produced no usable token, so that the requests this
- * session makes over the next {@link #RENEWAL_THROTTLE_INTERVAL} do not repeat it.
- *
- *
Shaped after {@code EntraIdAuthenticator#applyGraphThrottle}, with a fixed interval
- * rather than a negotiated one: MSAL4J reports the failure as a null result, so there is
- * no {@code Retry-After} to read.
- *
- * @param currentTime The current time in epoch milliseconds.
- */
- protected void applyRenewalThrottle(final long currentTime) {
- renewalThrottledUntil = currentTime + RENEWAL_THROTTLE_INTERVAL;
- }
-
- /**
- * Gets the Entra ID authentication result.
- * @return The authentication result.
- */
- public IAuthenticationResult getAuthenticationResult() {
- return authResult;
- }
-
- /**
- * Sets the user's group memberships.
- * @param groups Array of group names.
- */
- public synchronized void setGroups(final String[] groups) {
- this.groups = groups;
- }
-
- /**
- * Sets the user's role assignments.
- * @param roles Array of role names.
- */
- public synchronized void setRoles(final String[] roles) {
- this.roles = roles;
- }
-
- @Override
- public PermissionState getPermissionState() {
- return permissionState;
- }
-
- /**
- * Records how far the group and role resolution has got.
- * @param permissionState The state.
- */
- public void setPermissionState(final PermissionState permissionState) {
- this.permissionState = permissionState;
- }
-
- /**
- * Whether a membership resolution has ever run to completion for this user.
- * @return True once one has, so that a later one is a re-resolution.
- */
- public boolean isResolutionCompleted() {
- return resolutionCompleted;
- }
-
- /**
- * Records that a membership resolution has run to completion, so that the next one is a
- * re-resolution and must not overwrite what this one wrote with the defaults alone.
- */
- public void markResolutionCompleted() {
- this.resolutionCompleted = true;
- }
-
- /**
- * Resets permissions to force recalculation on next getPermissions() call.
- * Called from within {@code updateMemberOf}, before the permission state write, once the
- * asynchronous membership resolution has the new groups and roles in hand.
- */
- public synchronized void resetPermissions() {
- this.permissions = null;
- }
- }
-}
diff --git a/src/main/java/org/codelibs/fess/app/web/base/login/OpenIdConnectCredential.java b/src/main/java/org/codelibs/fess/app/web/base/login/OpenIdConnectCredential.java
deleted file mode 100644
index 0e9b8cc3a..000000000
--- a/src/main/java/org/codelibs/fess/app/web/base/login/OpenIdConnectCredential.java
+++ /dev/null
@@ -1,185 +0,0 @@
-/*
- * Copyright 2012-2025 CodeLibs Project and the Others.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
- * either express or implied. See the License for the specific language
- * governing permissions and limitations under the License.
- */
-package org.codelibs.fess.app.web.base.login;
-
-import static org.codelibs.core.stream.StreamUtil.split;
-import static org.codelibs.core.stream.StreamUtil.stream;
-
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-
-import org.codelibs.core.lang.StringUtil;
-import org.codelibs.fess.entity.FessUser;
-import org.codelibs.fess.helper.SystemHelper;
-import org.codelibs.fess.util.ComponentUtil;
-import org.codelibs.fess.util.DocumentUtil;
-import org.lastaflute.web.login.credential.LoginCredential;
-
-/**
- * OpenID Connect credential implementation.
- */
-public class OpenIdConnectCredential implements LoginCredential, FessCredential {
-
- private final Map attributes;
-
- /**
- * Creates a new OpenID Connect credential.
- *
- * @param attributes the attributes from OpenID Connect provider
- */
- public OpenIdConnectCredential(final Map attributes) {
- this.attributes = attributes;
- }
-
- @Override
- public String toString() {
- return "{" + getUserId() + "}";
- }
-
- @Override
- public String getUserId() {
- return DocumentUtil.getValue(attributes, "email", String.class);
- }
-
- /**
- * Gets the user groups.
- *
- * @return the user groups
- */
- public String[] getUserGroups() {
- if (attributes.get("groups") instanceof final String singleGroup) {
- // A provider that collapses a single-valued claim to a bare JSON string still named a
- // group. DocumentUtil answers null when a String is asked for as a String[], which is the
- // same answer it gives for a claim that was never sent, so this used to be indistinguishable
- // from an absent claim and the user silently got oic.default.groups instead of their own
- // group. An empty value is treated like an empty array: the claim was sent, so the default
- // does not apply.
- return StringUtil.isBlank(singleGroup) ? StringUtil.EMPTY_STRINGS : new String[] { singleGroup.trim() };
- }
- String[] userGroups = DocumentUtil.getValue(attributes, "groups", String[].class);
- if (userGroups == null) {
- userGroups = getDefaultGroupsAsArray();
- }
- return userGroups;
- }
-
- /**
- * Gets the OpenID Connect user.
- *
- * @return the OpenID Connect user
- */
- public OpenIdUser getUser() {
- return new OpenIdUser(getUserId(), getUserGroups(), getDefaultRolesAsArray());
- }
-
- /**
- * Gets the default groups as an array.
- *
- * @return the default groups
- */
- protected static String[] getDefaultGroupsAsArray() {
- final String value = ComponentUtil.getFessConfig().getSystemProperty("oic.default.groups");
- if (StringUtil.isBlank(value)) {
- return StringUtil.EMPTY_STRINGS;
- }
- return split(value, ",").get(stream -> stream.filter(StringUtil::isNotBlank).map(String::trim).toArray(n -> new String[n]));
- }
-
- /**
- * Gets the default roles as an array.
- *
- * @return the default roles
- */
- protected static String[] getDefaultRolesAsArray() {
- final String value = ComponentUtil.getFessConfig().getSystemProperty("oic.default.roles");
- if (StringUtil.isBlank(value)) {
- return StringUtil.EMPTY_STRINGS;
- }
- return split(value, ",").get(stream -> stream.filter(StringUtil::isNotBlank).map(String::trim).toArray(n -> new String[n]));
- }
-
- /**
- * OpenID Connect user implementation.
- */
- public static class OpenIdUser implements FessUser {
-
- private static final long serialVersionUID = 1L;
-
- /** The user name. */
- protected final String name;
-
- /** The user groups. */
- protected String[] groups;
-
- /** The user roles. */
- protected String[] roles;
-
- /**
- * The user permissions.
- *
- * Lazily computed by {@link #getPermissions()} and never invalidated afterwards.
- * {@code volatile} for the same reason as in
- * {@link org.codelibs.fess.app.web.base.login.SamlCredential.SamlUser}: the enclosing bean is
- * a session attribute shared by concurrent requests, and the array is written after the
- * session publication, so an unsynchronized reader could see the reference before the
- * elements.
- */
- protected volatile String[] permissions;
-
- /**
- * Creates a new OpenID Connect user.
- *
- * @param name the user name
- * @param groups the user groups
- * @param roles the user roles
- */
- protected OpenIdUser(final String name, final String[] groups, final String[] roles) {
- this.name = name;
- this.groups = groups;
- this.roles = roles;
- }
-
- @Override
- public String getName() {
- return name;
- }
-
- @Override
- public String[] getRoleNames() {
- return roles;
- }
-
- @Override
- public String[] getGroupNames() {
- return groups;
- }
-
- @Override
- public String[] getPermissions() {
- if (permissions == null) {
- final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
- final Set permissionSet = new HashSet<>();
- permissionSet.add(systemHelper.getSearchRoleByDirectoryUser(name));
- stream(groups).of(stream -> stream.forEach(s -> permissionSet.add(systemHelper.getSearchRoleByDirectoryGroup(s))));
- stream(roles).of(stream -> stream.forEach(s -> permissionSet.add(systemHelper.getSearchRoleByDirectoryRole(s))));
- permissions = permissionSet.toArray(new String[permissionSet.size()]);
- }
- return permissions;
- }
-
- }
-}
diff --git a/src/main/java/org/codelibs/fess/app/web/base/login/SamlCredential.java b/src/main/java/org/codelibs/fess/app/web/base/login/SamlCredential.java
deleted file mode 100644
index 668bfaf8a..000000000
--- a/src/main/java/org/codelibs/fess/app/web/base/login/SamlCredential.java
+++ /dev/null
@@ -1,273 +0,0 @@
-/*
- * Copyright 2012-2025 CodeLibs Project and the Others.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
- * either express or implied. See the License for the specific language
- * governing permissions and limitations under the License.
- */
-package org.codelibs.fess.app.web.base.login;
-
-import static org.codelibs.core.stream.StreamUtil.split;
-import static org.codelibs.core.stream.StreamUtil.stream;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-import org.codelibs.core.lang.StringUtil;
-import org.codelibs.fess.entity.FessUser;
-import org.codelibs.fess.helper.SystemHelper;
-import org.codelibs.fess.mylasta.direction.FessConfig;
-import org.codelibs.fess.util.ComponentUtil;
-import org.codelibs.saml2.Auth;
-import org.lastaflute.web.login.credential.LoginCredential;
-
-/**
- * Credential for SAML authentication.
- */
-public class SamlCredential implements LoginCredential, FessCredential {
-
- private final Map> attributes;
-
- private final String nameId;
-
- private final String nameIdFormat;
-
- private final String sessionIndex;
-
- private final String nameidNameQualifier;
-
- private final String nameidSPNameQualifier;
-
- /**
- * Constructor.
- * @param auth The SAML authentication.
- */
- public SamlCredential(final Auth auth) {
- attributes = auth.getAttributes();
- nameId = auth.getNameId();
- nameIdFormat = auth.getNameIdFormat();
- sessionIndex = auth.getSessionIndex();
- nameidNameQualifier = auth.getNameIdNameQualifier();
- nameidSPNameQualifier = auth.getNameIdSPNameQualifier();
- }
-
- @Override
- public String toString() {
- return "{" + getUserId() + "}";
- }
-
- @Override
- public String getUserId() {
- return nameId;
- }
-
- /**
- * Gets the SAML user.
- * @return The SAML user.
- */
- public SamlUser getUser() {
- return new SamlUser(nameId, sessionIndex, nameIdFormat, nameidNameQualifier, nameidSPNameQualifier, getDefaultGroupsAsArray(),
- getDefaultRolesAsArray());
- }
-
- /**
- * Gets the default groups as an array.
- * @return The default groups as an array.
- */
- protected String[] getDefaultGroupsAsArray() {
- final List list = new ArrayList<>();
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- final String key = fessConfig.getSystemProperty("saml.attribute.group.name", "memberOf");
- if (StringUtil.isNotBlank(key)) {
- final List nameList = attributes.get(key);
- if (nameList != null) {
- list.addAll(nameList);
- }
- }
- final String value = fessConfig.getSystemProperty("saml.default.groups");
- if (StringUtil.isNotBlank(value)) {
- split(value, ",").of(stream -> stream.forEach(list::add));
- }
- return list.stream().filter(StringUtil::isNotBlank).map(String::trim).toArray(n -> new String[n]);
- }
-
- /**
- * Gets the default roles as an array.
- * @return The default roles as an array.
- */
- protected String[] getDefaultRolesAsArray() {
- final List list = new ArrayList<>();
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- final String key = fessConfig.getSystemProperty("saml.attribute.role.name");
- if (StringUtil.isNotBlank(key)) {
- final List nameList = attributes.get(key);
- if (nameList != null) {
- list.addAll(nameList);
- }
- }
- final String value = fessConfig.getSystemProperty("saml.default.roles");
- if (StringUtil.isNotBlank(value)) {
- split(value, ",").of(stream -> stream.forEach(list::add));
- }
- return list.stream().filter(StringUtil::isNotBlank).map(String::trim).toArray(n -> new String[n]);
- }
-
- /**
- * Represents a SAML user.
- */
- public static class SamlUser implements FessUser {
-
- private static final long serialVersionUID = 1L;
-
- /**
- * The groups of the user.
- */
- protected String[] groups;
-
- /**
- * The roles of the user.
- */
- protected String[] roles;
-
- /**
- * The permissions of the user.
- *
- * Lazily computed by {@link #getPermissions()} and never invalidated afterwards.
- * {@code volatile} because the enclosing {@link org.codelibs.fess.mylasta.action.FessUserBean}
- * is a session attribute read by every concurrent request of that session, while the array
- * is written on whichever thread calls {@code getPermissions()} first. That write is not
- * ordered by the session publication -- LastaFlute stores the bean before the login success
- * callback that warms this cache -- so without {@code volatile} a request already in flight
- * could observe the reference while the elements are still unwritten and see an incomplete
- * permission set. Two threads racing to compute it is harmless; they produce equal arrays.
- */
- protected volatile String[] permissions;
-
- /**
- * The name ID of the user.
- */
- protected String nameId;
-
- /**
- * The session index of the user.
- */
- protected String sessionIndex;
-
- /**
- * The name ID format of the user.
- */
- protected String nameIdFormat;
-
- /**
- * The name ID name qualifier of the user.
- */
- protected String nameidNameQualifier;
-
- /**
- * The name ID SP name qualifier of the user.
- */
- protected String nameidSPNameQualifier;
-
- /**
- * Constructor.
- * @param nameId The name ID.
- * @param sessionIndex The session index.
- * @param nameIdFormat The name ID format.
- * @param nameidNameQualifier The name ID name qualifier.
- * @param nameidSPNameQualifier The name ID SP name qualifier.
- * @param groups The groups.
- * @param roles The roles.
- */
- public SamlUser(final String nameId, final String sessionIndex, final String nameIdFormat, final String nameidNameQualifier,
- final String nameidSPNameQualifier, final String[] groups, final String[] roles) {
- this.nameId = nameId;
- this.sessionIndex = sessionIndex;
- this.nameIdFormat = nameIdFormat;
- this.nameidNameQualifier = nameidNameQualifier;
- this.nameidSPNameQualifier = nameidSPNameQualifier;
- this.groups = groups;
- this.roles = roles;
- }
-
- @Override
- public String getName() {
- return nameId;
- }
-
- @Override
- public String[] getRoleNames() {
- return roles;
- }
-
- @Override
- public String[] getGroupNames() {
- return groups;
- }
-
- @Override
- public String[] getPermissions() {
- if (permissions == null) {
- final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
- final Set permissionSet = new HashSet<>();
- permissionSet.add(systemHelper.getSearchRoleByDirectoryUser(nameId));
- stream(groups).of(stream -> stream.forEach(s -> permissionSet.add(systemHelper.getSearchRoleByDirectoryGroup(s))));
- stream(roles).of(stream -> stream.forEach(s -> permissionSet.add(systemHelper.getSearchRoleByDirectoryRole(s))));
- permissions = permissionSet.toArray(new String[permissionSet.size()]);
- }
- return permissions;
- }
-
- /**
- * Gets the session index.
- * @return The session index.
- */
- public String getSessionIndex() {
- return sessionIndex;
- }
-
- /**
- * Gets the name ID format.
- * @return The name ID format.
- */
- public String getNameIdFormat() {
- return nameIdFormat;
- }
-
- /**
- * Gets the name ID name qualifier.
- * @return The name ID name qualifier.
- */
- public String getNameidNameQualifier() {
- return nameidNameQualifier;
- }
-
- /**
- * Gets the name ID SP name qualifier.
- * @return The name ID SP name qualifier.
- */
- public String getNameidSPNameQualifier() {
- return nameidSPNameQualifier;
- }
-
- @Override
- public String toString() {
- return "SamlUser [groups=" + Arrays.toString(groups) + ", roles=" + Arrays.toString(roles) + ", permissions="
- + Arrays.toString(permissions) + ", nameId=" + nameId + ", sessionIndex=" + sessionIndex + ", nameIdFormat="
- + nameIdFormat + ", nameidNameQualifier=" + nameidNameQualifier + ", nameidSPNameQualifier=" + nameidSPNameQualifier
- + "]";
- }
-
- }
-}
diff --git a/src/main/java/org/codelibs/fess/app/web/base/login/SpnegoCredential.java b/src/main/java/org/codelibs/fess/app/web/base/login/SpnegoCredential.java
deleted file mode 100644
index 107c13f92..000000000
--- a/src/main/java/org/codelibs/fess/app/web/base/login/SpnegoCredential.java
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * Copyright 2012-2025 CodeLibs Project and the Others.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
- * either express or implied. See the License for the specific language
- * governing permissions and limitations under the License.
- */
-package org.codelibs.fess.app.web.base.login;
-
-import org.lastaflute.web.login.credential.LoginCredential;
-
-/**
- * SPNEGO authentication credential implementation.
- *
- * This class represents login credentials obtained through SPNEGO (Security Provider
- * Negotiation Protocol) authentication. It contains the username extracted from the
- * SPNEGO authentication process, typically from a Kerberos ticket.
- */
-public class SpnegoCredential implements LoginCredential, FessCredential {
-
- /** The username extracted from SPNEGO authentication. */
- private final String username;
-
- /**
- * Constructs a new SpnegoCredential with the specified username.
- *
- * @param username The username obtained from SPNEGO authentication
- */
- public SpnegoCredential(final String username) {
- this.username = username;
- }
-
- /**
- * Gets the user identifier from this credential.
- *
- * @return The username from SPNEGO authentication
- */
- @Override
- public String getUserId() {
- return username;
- }
-
- /**
- * Returns a string representation of this credential.
- *
- * @return A string representation containing the username in braces
- */
- @Override
- public String toString() {
- return "{" + username + "}";
- }
-
-}
\ No newline at end of file
diff --git a/src/main/java/org/codelibs/fess/helper/PluginHelper.java b/src/main/java/org/codelibs/fess/helper/PluginHelper.java
index a37a7bb58..daa2a3380 100644
--- a/src/main/java/org/codelibs/fess/helper/PluginHelper.java
+++ b/src/main/java/org/codelibs/fess/helper/PluginHelper.java
@@ -610,9 +610,11 @@ public enum ArtifactType {
*/
STORAGE("fess-storage"), //
/**
- * Single sign-on plugins, contributing the {@code SsoAuthenticator} that {@code sso.type}
- * selects. No such plugin exists as of 15.9 - the authenticators still ship in core - so
- * this reserves the prefix for when they are split out.
+ * Single sign-on plugins, each contributing the {@code Authenticator} that
+ * {@code sso.type} selects, plus the identity library it needs, so the distribution does
+ * not carry one for every installation. There are four: fess-sso-saml, fess-sso-spnego,
+ * fess-sso-entraid and fess-sso-oidc. Core keeps SsoManager and the /sso/ endpoints, and
+ * serves no type on its own.
*/
SSO("fess-sso"), //
/** Unknown/generic JAR files */
diff --git a/src/main/java/org/codelibs/fess/sso/SsoManager.java b/src/main/java/org/codelibs/fess/sso/SsoManager.java
index 42311a461..2d47374f1 100644
--- a/src/main/java/org/codelibs/fess/sso/SsoManager.java
+++ b/src/main/java/org/codelibs/fess/sso/SsoManager.java
@@ -17,10 +17,13 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import org.codelibs.core.lang.StringUtil;
import org.codelibs.fess.Constants;
import org.codelibs.fess.mylasta.action.FessUserBean;
import org.codelibs.fess.util.ComponentUtil;
@@ -34,6 +37,11 @@
* It manages registered SSO authenticators, determines when SSO is available,
* and delegates authentication operations to the appropriate SSO provider based
* on the current configuration.
+ *
+ * No authenticator ships in the distribution: each one is a fess-sso-* plugin that contributes
+ * its {@code Authenticator} component through {@code fess_sso++.xml}. What this class
+ * owns is the mapping from an {@code sso.type} value to that component name, so a plugin needs no
+ * change here to be reachable.
*/
public class SsoManager {
/** Logger for this class. */
@@ -42,6 +50,16 @@ public class SsoManager {
/** List of registered SSO authenticators. */
protected final List authenticatorList = new ArrayList<>();
+ /**
+ * The {@code sso.type} values already reported as unserved, so that each one is logged once
+ * rather than once per request.
+ *
+ * {@code /sso/} is anonymous, and the same miss is hit on every visit to it, so a warning
+ * per attempt is a log an unauthenticated client can fill. The set is bounded by the number of
+ * distinct values the setting has held for the life of the JVM, which is one.
+ */
+ protected final Set unservedSsoTypes = ConcurrentHashMap.newKeySet();
+
/**
* Default constructor for creating a new SsoManager instance.
*/
@@ -111,6 +129,12 @@ protected T withAuthenticator(final Function operation)
/**
* Gets the SSO authenticator instance for the configured SSO type.
*
+ * The type is resolved to the component name {@code Authenticator} rather than to
+ * a class this package knows about, which is what lets the authenticators ship as plugins: a
+ * type core has never heard of reaches a plugin that registers that name. A type whose plugin
+ * is not installed is therefore a configuration error rather than a missing class, and is
+ * reported by {@link #reportUnservedSsoType}.
+ *
* @return The SSO authenticator instance, or null if not found
*/
protected SsoAuthenticator getAuthenticator() {
@@ -123,9 +147,40 @@ protected SsoAuthenticator getAuthenticator() {
if (ComponentUtil.hasComponent(name)) {
return ComponentUtil.getComponent(name);
}
+ reportUnservedSsoType(ssoType, name);
return null;
}
+ /**
+ * Reports that no component serves the configured {@code sso.type}.
+ *
+ * Every caller of {@link #getAuthenticator()} answers null when this happens, and
+ * {@code SsoAction.index()} turns that null into {@code errors.sso_login_error} and a redirect
+ * to the login page. Without this the whole symptom is a {@code GET /sso/} that answers 302 to
+ * {@code /login/}, with nothing in the log at any level to say why -- the configuration is
+ * complete and correct, and the piece that is missing is a plugin. That became the ordinary
+ * upgrade path when the authenticators were split out of core, so it is said out loud.
+ *
+ * WARN rather than ERROR: in Fess an ERROR line is a notification trigger, and this is a
+ * deployment that has not finished installing rather than a fault at run time. Blank and
+ * {@code none} are the states of a deployment that does not use SSO at all and say nothing --
+ * {@code /sso/} is reachable whether or not it is configured.
+ *
+ * @param ssoType the configured type, after the legacy {@code aad} mapping
+ * @param componentName the component name it resolved to
+ */
+ protected void reportUnservedSsoType(final String ssoType, final String componentName) {
+ if (StringUtil.isBlank(ssoType) || Constants.NONE.equals(ssoType)) {
+ return;
+ }
+ if (logger.isWarnEnabled() && unservedSsoTypes.add(ssoType)) {
+ logger.warn("No SSO authenticator is registered as {} for sso.type={}. Every authenticator ships as a fess-sso-* plugin: "
+ + "install fess-sso-saml for saml, fess-sso-spnego for spnego, fess-sso-entraid for entraid "
+ + "(or the legacy aad), or fess-sso-oidc for oic. Until then every request to /sso/ is redirected "
+ + "back to the login page.", componentName, ssoType);
+ }
+ }
+
/**
* Gets the configured SSO type from the system configuration.
*
diff --git a/src/main/java/org/codelibs/fess/sso/entraid/EntraIdAuthenticator.java b/src/main/java/org/codelibs/fess/sso/entraid/EntraIdAuthenticator.java
deleted file mode 100644
index 1bff19dfd..000000000
--- a/src/main/java/org/codelibs/fess/sso/entraid/EntraIdAuthenticator.java
+++ /dev/null
@@ -1,2146 +0,0 @@
-/*
- * Copyright 2012-2025 CodeLibs Project and the Others.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
- * either express or implied. See the License for the specific language
- * governing permissions and limitations under the License.
- */
-package org.codelibs.fess.sso.entraid;
-
-import static org.codelibs.core.stream.StreamUtil.split;
-
-import java.io.IOException;
-import java.net.URI;
-import java.net.URLEncoder;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
-import java.util.UUID;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.stream.Collectors;
-
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
-import org.codelibs.core.lang.StringUtil;
-import org.codelibs.core.misc.Pair;
-import org.codelibs.core.timer.TimeoutManager;
-import org.codelibs.curl.Curl;
-import org.codelibs.curl.CurlException;
-import org.codelibs.curl.CurlRequest;
-import org.codelibs.curl.CurlResponse;
-import org.codelibs.fess.app.web.base.login.ActionResponseCredential;
-import org.codelibs.fess.app.web.base.login.EntraIdCredential;
-import org.codelibs.fess.app.web.base.login.EntraIdCredential.EntraIdUser;
-import org.codelibs.fess.app.web.base.login.FessLoginAssist.LoginCredentialResolver;
-import org.codelibs.fess.crawler.Constants;
-import org.codelibs.fess.entity.FessUser.PermissionState;
-import org.codelibs.fess.exception.SsoLoginException;
-import org.codelibs.fess.exception.SsoStateException;
-import org.codelibs.fess.mylasta.action.FessUserBean;
-import org.codelibs.fess.mylasta.direction.FessConfig;
-import org.codelibs.fess.sso.SsoAuthenticator;
-import org.codelibs.fess.util.ComponentUtil;
-import org.codelibs.fess.util.DocumentUtil;
-import org.codelibs.fess.util.SearchEngineCurl;
-import org.dbflute.optional.OptionalEntity;
-import org.dbflute.optional.OptionalThing;
-import org.lastaflute.web.login.credential.LoginCredential;
-import org.lastaflute.web.response.HtmlResponse;
-import org.lastaflute.web.util.LaRequestUtil;
-
-import com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
-import com.google.common.util.concurrent.UncheckedExecutionException;
-import com.microsoft.aad.msal4j.AuthorizationCodeParameters;
-import com.microsoft.aad.msal4j.ConfidentialClientApplication;
-import com.microsoft.aad.msal4j.IAccount;
-import com.microsoft.aad.msal4j.IAuthenticationResult;
-import com.microsoft.aad.msal4j.RefreshTokenParameters;
-import com.microsoft.aad.msal4j.SilentParameters;
-import com.nimbusds.jwt.JWTClaimsSet;
-import com.nimbusds.jwt.JWTParser;
-import com.nimbusds.oauth2.sdk.AuthorizationCode;
-import com.nimbusds.openid.connect.sdk.AuthenticationErrorResponse;
-import com.nimbusds.openid.connect.sdk.AuthenticationResponse;
-import com.nimbusds.openid.connect.sdk.AuthenticationResponseParser;
-import com.nimbusds.openid.connect.sdk.AuthenticationSuccessResponse;
-
-import jakarta.annotation.PostConstruct;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpSession;
-
-/**
- * Microsoft Entra ID SSO authenticator implementation.
- * Handles OAuth2/OpenID Connect authentication flow with Entra ID.
- */
-public class EntraIdAuthenticator implements SsoAuthenticator {
-
- private static final Logger logger = LogManager.getLogger(EntraIdAuthenticator.class);
-
- /**
- * Default constructor for EntraIdAuthenticator.
- */
- public EntraIdAuthenticator() {
- // Default constructor
- }
-
- // New configuration keys for Entra ID
- /** Configuration key for Entra ID state time-to-live. */
- protected static final String ENTRAID_STATE_TTL = "entraid.state.ttl";
-
- /** Default state time-to-live in seconds. */
- protected static final String DEFAULT_STATE_TTL = "3600";
-
- /**
- * Authority used when none is configured. Also used when the configured value is present but
- * blank: an empty authority would make {@link #getAuthUrl} build a scheme-less, and therefore
- * relative, redirect that sends the browser back into Fess instead of to Microsoft.
- */
- protected static final String DEFAULT_AUTHORITY = "https://login.microsoftonline.com/";
-
- /** Configuration key for Entra ID authority URL. */
- protected static final String ENTRAID_AUTHORITY = "entraid.authority";
-
- /** Configuration key for Entra ID tenant ID. */
- protected static final String ENTRAID_TENANT = "entraid.tenant";
-
- /** Configuration key for Entra ID client secret. */
- protected static final String ENTRAID_CLIENT_SECRET = "entraid.client.secret";
-
- /** Configuration key for Entra ID client ID. */
- protected static final String ENTRAID_CLIENT_ID = "entraid.client.id";
-
- /** Configuration key for Entra ID reply URL. */
- protected static final String ENTRAID_REPLY_URL = "entraid.reply.url";
-
- /** Configuration key for the OAuth2 response mode of the authorization request. */
- protected static final String ENTRAID_RESPONSE_MODE = "entraid.response.mode";
-
- /** Configuration key for Entra ID default groups. */
- protected static final String ENTRAID_DEFAULT_GROUPS = "entraid.default.groups";
-
- /** Configuration key for Entra ID default roles. */
- protected static final String ENTRAID_DEFAULT_ROLES = "entraid.default.roles";
-
- // Legacy configuration keys for backward compatibility (Azure AD)
- /** Legacy configuration key for Azure AD state time-to-live. */
- protected static final String AAD_STATE_TTL = "aad.state.ttl";
-
- /** Legacy configuration key for Azure AD authority URL. */
- protected static final String AAD_AUTHORITY = "aad.authority";
-
- /** Legacy configuration key for Azure AD tenant ID. */
- protected static final String AAD_TENANT = "aad.tenant";
-
- /** Legacy configuration key for Azure AD client secret. */
- protected static final String AAD_CLIENT_SECRET = "aad.client.secret";
-
- /** Legacy configuration key for Azure AD client ID. */
- protected static final String AAD_CLIENT_ID = "aad.client.id";
-
- /** Legacy configuration key for Azure AD reply URL. */
- protected static final String AAD_REPLY_URL = "aad.reply.url";
-
- /** Legacy configuration key for the OAuth2 response mode. */
- protected static final String AAD_RESPONSE_MODE = "aad.response.mode";
-
- /** Response mode that returns the authorization code in the callback query string. */
- protected static final String RESPONSE_MODE_QUERY = "query";
-
- /** Response mode that returns the authorization code in a form POST to the callback. */
- protected static final String RESPONSE_MODE_FORM_POST = "form_post";
-
- /** Legacy configuration key for Azure AD default groups. */
- protected static final String AAD_DEFAULT_GROUPS = "aad.default.groups";
-
- /** Legacy configuration key for Azure AD default roles. */
- protected static final String AAD_DEFAULT_ROLES = "aad.default.roles";
-
- /** Session attribute key for storing Entra ID states. */
- protected static final String STATES = "entraidStates";
-
- /** OAuth2 state parameter name. */
- protected static final String STATE = "state";
-
- /** OAuth2 error parameter name. */
- protected static final String ERROR = "error";
-
- /** OAuth2 error description parameter name. */
- protected static final String ERROR_DESCRIPTION = "error_description";
-
- /** OAuth2 error URI parameter name. */
- protected static final String ERROR_URI = "error_uri";
-
- /** OpenID Connect ID token parameter name. */
- protected static final String ID_TOKEN = "id_token";
-
- /** OAuth2 authorization code parameter name. */
- protected static final String CODE = "code";
-
- /** Microsoft Graph error code returned when the application lacks the required permission. */
- protected static final String PERMISSION_DENIED_ERROR_CODE = "Authorization_RequestDenied";
-
- /** HTTP status Microsoft Graph answers with while it is throttling the caller. */
- protected static final int HTTP_TOO_MANY_REQUESTS = 429;
-
- /** HTTP status Microsoft Graph answers with while it is temporarily unavailable. */
- protected static final int HTTP_SERVICE_UNAVAILABLE = 503;
-
- /** Backoff applied when a throttled response carries no usable {@code Retry-After} header. */
- protected static final long DEFAULT_GRAPH_THROTTLE_SECONDS = 60L;
-
- /**
- * Upper bound on the backoff. {@code Retry-After} is whatever the service says it is, and an
- * unreasonably large value would leave nested groups unresolved for the rest of the day.
- */
- protected static final long MAX_GRAPH_THROTTLE_SECONDS = 60L * 60L;
-
- /** The Microsoft Graph scope that asks for the permissions granted to the app registration. */
- protected static final String GRAPH_DEFAULT_SCOPE = "https://graph.microsoft.com/.default";
-
- /** Base URL of the Microsoft Graph v1.0 endpoint. */
- protected static final String GRAPH_V1_URL = "https://graph.microsoft.com/v1.0";
-
- /**
- * Scopes requested at the v2.0 authorization endpoint. msal4j already prepends
- * {@code openid profile offline_access} to the token request (its
- * {@code OAuthAuthorizationGrant.COMMON_SCOPES}), so naming them here as well means consent is
- * asked for the same set the token exchange goes on to request, rather than relying on the
- * app registration's static permissions happening to include them.
- */
- protected static final String V2_SCOPES = "openid profile offline_access " + GRAPH_DEFAULT_SCOPE;
-
- /** {@link #V2_SCOPES} percent-encoded once, since it never varies. */
- protected static final String V2_SCOPES_ENCODED = URLEncoder.encode(V2_SCOPES, Constants.UTF_8_CHARSET);
-
- /**
- * Response parameters whose values are credentials. Their values are truncated before being
- * written to a debug log; every other parameter is logged verbatim so that a failed login can
- * still be diagnosed from {@code state}, {@code error} and {@code error_description}.
- */
- protected static final Set SENSITIVE_PARAMS = Set.of(CODE, ID_TOKEN, "access_token", "refresh_token", "client_secret");
-
- /** Number of leading characters kept when a secret is written to a debug log. */
- protected static final int MASK_PREFIX_LENGTH = 8;
-
- /**
- * Truncates a secret so it can be correlated across log lines without being usable.
- * Null and empty values are passed through, because several call sites log a field that
- * the identity provider may not have sent at all.
- *
- * @param value The value to mask.
- * @return The masked value.
- */
- protected static String maskSecret(final String value) {
- if (StringUtil.isEmpty(value)) {
- return value;
- }
- return value.substring(0, Math.min(MASK_PREFIX_LENGTH, value.length())) + "***";
- }
-
- /**
- * Drops the query string from a URL before it is written to a debug log. The query string can
- * carry the authorization code, and every parameter it holds is already logged separately via
- * {@link #maskParams(Map)}.
- *
- * @param url The URL to strip.
- * @return The URL without its query string.
- */
- protected static String maskQueryString(final String url) {
- if (url == null) {
- return null;
- }
- final int index = url.indexOf('?');
- return index < 0 ? url : url.substring(0, index);
- }
-
- /**
- * Returns a copy of the response parameters with credential values masked, for logging.
- * The key set is preserved so the log still shows which artifacts the identity provider sent.
- *
- * @param params The response parameters.
- * @return A new map safe to write to a log.
- */
- protected static Map> maskParams(final Map> params) {
- final Map> maskedParams = new LinkedHashMap<>();
- params.forEach((key, values) -> {
- if (key != null && SENSITIVE_PARAMS.contains(key.toLowerCase(Locale.ENGLISH))) {
- maskedParams.put(key, values.stream().map(EntraIdAuthenticator::maskSecret).collect(Collectors.toList()));
- } else {
- maskedParams.put(key, values);
- }
- });
- return maskedParams;
- }
-
- /** Timeout for token acquisition in milliseconds. */
- protected long acquisitionTimeout = 30 * 1000L;
-
- /** Cache for storing group information to reduce API calls. */
- protected Cache> groupCache;
-
- /** Group cache expiry time in seconds. */
- protected long groupCacheExpiry = 10 * 60L;
-
- /**
- * Maximum number of groups kept in {@link #groupCache}. The cache is keyed by group id and the
- * configured permission fields, so a tenant with many groups would otherwise grow it without
- * bound until every entry expired.
- */
- protected int maxGroupCacheSize = 10000;
-
- /** Maximum depth for processing nested groups to prevent infinite loops. */
- protected int maxGroupDepth = 10;
-
- /**
- * How many consecutive parent group lookups Microsoft Graph may fail to answer before
- * {@link #updateMemberOf} stops walking the rest of the user's direct groups.
- *
- * The walk costs one {@code POST /groups/{id}/getMemberGroups} per direct group. A 429 or a
- * 503 records a tenant-wide backoff, so the rest of that walk is skipped without reaching
- * Graph at all; a 500/502/504 or a transport failure -- DNS, connection refused, or the
- * {@link #graphConnectTimeout} / {@link #graphReadTimeout} expiring -- records nothing, so
- * without this bound every direct group costs a full request and a stack trace, on every
- * login, each waiting out the timeouts. That runs on corelib's shared {@code TimeoutManager}
- * pool, whose {@code CallerRunsPolicy} pushes the overflow onto the timer thread itself.
- *
- *
Consecutive rather than total is deliberate: one permanently broken group id must not
- * stop the rest of the walk, while a Graph that has stopped answering trips the bound
- * immediately.
- */
- protected int maxConsecutiveGroupLookupFailures = 3;
-
- /**
- * Connection timeout for Microsoft Graph requests in milliseconds. curl4j leaves this unset,
- * which means an unbounded wait, and the direct-membership lookup runs on the login thread.
- */
- protected int graphConnectTimeout = 10 * 1000;
-
- /** Read timeout for Microsoft Graph requests in milliseconds. See {@link #graphConnectTimeout}. */
- protected int graphReadTimeout = 30 * 1000;
-
- /**
- * Maximum number of unfinished authorization attempts kept per session. Each redirect to the
- * authorization endpoint stores one; without a cap, a client that keeps starting logins
- * without finishing one grows the session attribute without bound.
- */
- protected int maxStates = 10;
-
- /**
- * Point in time, as epoch milliseconds, until which Microsoft Graph asked us to stop calling
- * it. Zero means it never did. Read on the login path, written from whichever thread was
- * throttled, hence volatile.
- */
- protected volatile long graphThrottledUntil;
-
- /**
- * Shared MSAL4J client application together with the configuration it was built from.
- *
- *
A single reference is what makes the pair consistent: with the application and its key in
- * two separate fields, a reader interleaved between the two writes pairs the old application
- * with the new key and hands back the stale one indefinitely.
- */
- protected volatile ClientApplicationHolder clientApplicationHolder;
-
- /**
- * The accounts whose tokens are in the shared application's cache, in least-recently-acquired
- * order. See {@link #maxCachedAccounts} for why this exists; access order rather than
- * insertion order because the entry worth evicting is the one whose tokens have gone longest
- * without being used, not the one that logged in first -- a session that has been alive for
- * days is the last one to throw away.
- *
- *
Guarded by its own monitor. It is only ever touched after an acquisition or a logout,
- * so contention is a fraction of what the login path already serialises on.
- */
- protected final Map cachedAccounts = new LinkedHashMap<>(16, 0.75f, true);
-
- /**
- * Maximum number of accounts kept in the shared application's token cache.
- *
- * MSAL4J's {@code TokenCache} is five plain maps with no size bound and no expiry, and
- * {@code removeAccount} is the only thing that takes anything out of them. While the
- * application was rebuilt per call there was nothing to accumulate; now that one instance is
- * shared so that silent refresh can work at all, every account that logged in and never
- * pressed Logout keeps an access token, a refresh token and an ID token resident until the
- * JVM restarts. The keys are account-scoped, so a user who logs in repeatedly overwrites
- * their own entry -- the bound is distinct accounts, not logins -- but a large tenant still
- * reaches a size worth capping, and a refresh token is good for up to 90 days.
- *
- *
The default is high enough that an ordinary deployment never reaches it. When it is
- * reached, the cost of evicting a live session is one silent re-authentication: a failed
- * silent acquisition leaves {@code refresh()} returning true until the access token actually
- * expires, and the re-login that follows goes through an unexpired Entra ID session.
- */
- protected int maxCachedAccounts = 10000;
-
- /**
- * Initializes the Entra ID authenticator.
- * Registers this authenticator with the SSO manager and sets up group cache.
- */
- @PostConstruct
- public void init() {
- if (logger.isDebugEnabled()) {
- logger.debug("Initializing {}", this.getClass().getSimpleName());
- }
- ComponentUtil.getSsoManager().register(this);
- groupCache = createGroupCache();
- }
-
- /**
- * Builds the parent group cache. Both bounds matter: the expiry keeps a group whose
- * membership changed from being served forever, and the size keeps a large tenant from
- * holding every group it ever resolved until the expiry comes round.
- *
- * @return The cache.
- */
- protected Cache> createGroupCache() {
- return CacheBuilder.newBuilder().maximumSize(maxGroupCacheSize).expireAfterWrite(groupCacheExpiry, TimeUnit.SECONDS).build();
- }
-
- @Override
- public LoginCredential getLoginCredential() {
- return LaRequestUtil.getOptionalRequest().map(request -> {
- if (logger.isDebugEnabled()) {
- logger.debug("Logging in with Entra ID Authenticator");
- }
- final HttpSession session = request.getSession(false);
- if (containsAuthenticationData(request)) {
- if (session != null) {
- try {
- return processAuthenticationData(request);
- } catch (final SsoLoginException e) {
- throw e;
- } catch (final Exception e) {
- // Wrapped rather than returned as null so SsoAction logs it at WARN and
- // shows the SSO error message, the same as it already does for the other
- // authenticators. Swallowing it here left a failed login invisible unless
- // DEBUG logging happened to be on.
- throw new SsoLoginException("Failed to process a login request on Entra ID.", e);
- }
- }
- if (!hasExpiredSession(request)) {
- // No session, and no session id came back either: the browser is not returning
- // the cookie at all (form_post with SameSite=Lax or Strict, or cookies off).
- // Redirecting would send the user straight back here in the same state, so
- // this is where the loop has to stop. Returning null makes SsoAction show the
- // SSO error message and fall back to the local login form.
- logger.warn("""
- Received an Entra ID authentication response without a session.\
- The session cookie was not sent back with the callback request.\
- See tomcat.sameSiteCookies in tomcat_config.properties.""");
- return null;
- }
- // The browser did return a session id and the container rejected it, so cookies
- // demonstrably work and the session merely expired while the user was at
- // Microsoft. Start the login again rather than dropping them on a login form that
- // has no SSO link. This cannot loop: getAuthUrl creates a fresh session, so the
- // next callback either finds it or arrives with no session id at all, which is
- // the branch above.
- if (logger.isDebugEnabled()) {
- logger.debug("The session of an Entra ID callback had expired. Restarting the login.");
- }
- }
-
- validateConfiguration();
- return new ActionResponseCredential(() -> HtmlResponse.fromRedirectPathAsIs(getAuthUrl(request)));
- }).orElse(null);
- }
-
- /**
- * Returns whether the request carries a session id that the container no longer recognises.
- *
- * This is what tells an expired session apart from a browser that is not sending the
- * cookie: a request with no session id at all cannot have lost one.
- *
- * @param request The HTTP servlet request.
- * @return True if a session id was sent and it is no longer valid.
- */
- protected boolean hasExpiredSession(final HttpServletRequest request) {
- return request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid();
- }
-
- /**
- * Fails the login when Entra ID cannot possibly answer for want of configuration.
- *
- *
Without this an unconfigured server redirects to
- * {@code https://login.microsoftonline.com//oauth2/v2.0/authorize?...&client_id=} and logs
- * nothing, so the administrator sees only a Microsoft error page. It is thrown from here
- * rather than from the {@link ActionResponseCredential} supplier because {@code SsoAction}
- * executes that supplier outside the block that catches {@link SsoLoginException}, and it is
- * not checked in {@code init()} because {@code fess_sso++.xml} registers every authenticator
- * unconditionally -- including when {@code sso.type} selects another one.
- */
- protected void validateConfiguration() {
- final List missing = new ArrayList<>();
- if (StringUtil.isBlank(getTenant())) {
- missing.add(ENTRAID_TENANT);
- }
- if (StringUtil.isBlank(getClientId())) {
- missing.add(ENTRAID_CLIENT_ID);
- }
- if (StringUtil.isBlank(getClientSecret())) {
- missing.add(ENTRAID_CLIENT_SECRET);
- }
- if (!missing.isEmpty()) {
- throw new SsoLoginException("Entra ID is not configured. The following settings are empty: " + String.join(", ", missing));
- }
- }
-
- /**
- * Generates the Entra ID authorization URL for the authentication request.
- * @param request The HTTP servlet request.
- * @return The authorization URL to redirect the user to.
- */
- protected String getAuthUrl(final HttpServletRequest request) {
- // UUID.randomUUID is backed by SecureRandom and varies in 122 bits. The state is the
- // only thing standing between a login and a forged callback (RFC 6749 section 10.12), and
- // org.codelibs.core.net.UuidUtil, which this used to call, keeps the first 16 hex
- // characters constant for the life of the JVM and varies under 32 bits per call.
- final String state = UUID.randomUUID().toString();
- final String nonce = UUID.randomUUID().toString();
- storeStateInSession(request.getSession(), state, nonce);
-
- final String responseMode = getResponseMode();
- final String authUrl = getAuthorityUrl() + "oauth2/v2.0/authorize?response_type=code&scope=" + V2_SCOPES_ENCODED + "&response_mode="
- + responseMode + "&redirect_uri=" + URLEncoder.encode(getReplyUrl(request), Constants.UTF_8_CHARSET) + "&client_id="
- + getClientId() + "&state=" + state + "&nonce=" + nonce;
- if (logger.isDebugEnabled()) {
- logger.debug("redirect to: {}", authUrl);
- }
- return authUrl;
-
- }
-
- /**
- * Stores state and nonce information in the HTTP session.
- * @param session The HTTP session.
- * @param state The OAuth2 state parameter.
- * @param nonce The OpenID Connect nonce parameter.
- */
- protected void storeStateInSession(final HttpSession session, final String state, final String nonce) {
- final Map stateMap = getStateMap(session);
- removeExpiredStates(stateMap);
- removeOldestStates(stateMap, maxStates - 1);
- final StateData stateData = new StateData(nonce, ComponentUtil.getSystemHelper().getCurrentTimeAsLong());
- if (logger.isDebugEnabled()) {
- logger.debug("Storing state in session: {}", stateData);
- }
- stateMap.put(state, stateData);
- }
-
- /**
- * Returns the per-session map of pending authorization attempts, creating it if needed.
- * The map is concurrent, and the create is synchronized on the session, because a user can
- * have several login attempts in flight at once -- a plain HashMap created twice loses the
- * state one of them has to validate later.
- *
- * @param session The HTTP session.
- * @return The state map held by the session.
- */
- protected Map getStateMap(final HttpSession session) {
- synchronized (session) {
- @SuppressWarnings("unchecked")
- final Map stateMap = (Map) session.getAttribute(STATES);
- if (stateMap instanceof ConcurrentHashMap) {
- return stateMap;
- }
- // Either absent, or a plain HashMap left by a session that predates this change.
- final Map concurrentMap = new ConcurrentHashMap<>();
- if (stateMap != null) {
- concurrentMap.putAll(stateMap);
- }
- session.setAttribute(STATES, concurrentMap);
- return concurrentMap;
- }
- }
-
- /**
- * Drops states that are older than the configured TTL.
- *
- * @param stateMap The state map to prune.
- */
- protected void removeExpiredStates(final Map stateMap) {
- final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
- final long stateTtl = getStateTtl();
- stateMap.entrySet()
- .stream()
- .filter(e -> (now - e.getValue().getExpiration()) / 1000L > stateTtl)
- .map(Map.Entry::getKey)
- .collect(Collectors.toList())
- .forEach(s -> {
- if (logger.isDebugEnabled()) {
- logger.debug("Removing old state: {}", s);
- }
- stateMap.remove(s);
- });
- }
-
- /**
- * Drops the least recently created states until at most {@code limit} remain. Unfinished
- * attempts never expire on their own before the TTL, so this is what bounds the map for a
- * client that keeps starting logins.
- *
- * @param stateMap The state map to prune.
- * @param limit The number of states to keep.
- */
- protected void removeOldestStates(final Map stateMap, final int limit) {
- if (stateMap.size() <= limit) {
- return;
- }
- stateMap.entrySet()
- .stream()
- .sorted(Comparator.comparingLong(e -> e.getValue().getExpiration()))
- .limit((long) stateMap.size() - limit)
- .map(Map.Entry::getKey)
- .collect(Collectors.toList())
- .forEach(s -> {
- if (logger.isDebugEnabled()) {
- logger.debug("Removing surplus state: {}", s);
- }
- stateMap.remove(s);
- });
- }
-
- /**
- * Sets the maximum number of pending authorization attempts kept per session.
- * @param maxStates The maximum number of states.
- */
- public void setMaxStates(final int maxStates) {
- this.maxStates = maxStates;
- }
-
- /**
- * Processes authentication data from the OAuth2 callback.
- * @param request The HTTP servlet request containing authentication data.
- * @return The login credential or null if processing fails.
- */
- protected LoginCredential processAuthenticationData(final HttpServletRequest request) {
- final StringBuilder urlBuf = new StringBuilder(request.getRequestURL());
- final String queryStr = request.getQueryString();
- if (queryStr != null) {
- urlBuf.append('?').append(queryStr);
- }
-
- final Map> params = new HashMap<>();
- for (final Map.Entry e : request.getParameterMap().entrySet()) {
- if (e.getValue().length > 0) {
- params.put(e.getKey(), Arrays.asList(e.getValue()));
- }
- }
- if (logger.isDebugEnabled()) {
- logger.debug("process authentication: url: {}, params: {}", request.getRequestURL(), maskParams(params));
- }
-
- // validate that state in response equals to state in request
- final StateData stateData = validateState(request.getSession(), params.containsKey(STATE) ? params.get(STATE).get(0) : null);
- if (logger.isDebugEnabled()) {
- logger.debug("Loading state: {}", stateData);
- }
-
- final AuthenticationResponse authResponse = parseAuthenticationResponse(urlBuf.toString(), params);
- if (authResponse instanceof final AuthenticationSuccessResponse oidcResponse) {
- validateAuthRespMatchesCodeFlow(oidcResponse);
- final IAuthenticationResult authData = getAccessToken(oidcResponse.getAuthorizationCode(), getReplyUrl(request));
- validateNonce(stateData, authData);
-
- return new EntraIdCredential(authData);
- }
- final AuthenticationErrorResponse oidcResponse = (AuthenticationErrorResponse) authResponse;
- throw new SsoLoginException(String.format("Request for auth code failed: %s - %s", oidcResponse.getErrorObject().getCode(),
- oidcResponse.getErrorObject().getDescription()));
- }
-
- /**
- * Parses the authentication response from Entra ID.
- * @param url The response URL.
- * @param params The response parameters.
- * @return The parsed authentication response.
- */
- protected AuthenticationResponse parseAuthenticationResponse(final String url, final Map> params) {
- if (logger.isDebugEnabled()) {
- logger.debug("Parse: {} : {}", maskQueryString(url), maskParams(params));
- }
- try {
- return AuthenticationResponseParser.parse(new URI(url), params);
- } catch (final Exception e) {
- throw new SsoLoginException("Failed to parse an authentication response.", e);
- }
- }
-
- /**
- * Validates the nonce in the authentication result.
- * @param stateData The stored state data containing the expected nonce.
- * @param authData The authentication result containing the actual nonce.
- */
- protected void validateNonce(final StateData stateData, final IAuthenticationResult authData) {
- final String idToken = authData.idToken();
- if (logger.isDebugEnabled()) {
- logger.debug("idToken={}", maskSecret(idToken));
- }
- try {
- final JWTClaimsSet claimsSet = JWTParser.parse(idToken).getJWTClaimsSet();
- if (claimsSet == null) {
- throw new SsoStateException("could not validate nonce");
- }
-
- final String nonce = (String) claimsSet.getClaim("nonce");
- if (logger.isDebugEnabled()) {
- logger.debug("nonce={}", nonce);
- }
- if (StringUtil.isEmpty(nonce) || !nonce.equals(stateData.getNonce())) {
- throw new SsoStateException("could not validate nonce");
- }
- } catch (final SsoLoginException e) {
- throw e;
- } catch (final Exception e) {
- // Not an SsoStateException: this is only reachable once the authorization code was
- // redeemed, so an unparsable or unreadable ID token is a fault worth a stack trace,
- // not a callback someone sent us.
- throw new SsoLoginException("could not validate nonce", e);
- }
- }
-
- /**
- * Returns the shared MSAL4J client application.
- *
- * Each {@link ConfidentialClientApplication} owns its own in-memory token cache, and
- * {@code acquireTokenSilently} throws {@code NO_TOKEN_IN_CACHE} on a miss. Building one per
- * call therefore made silent refresh impossible: the tokens acquired at login went into an
- * instance that was thrown away immediately afterwards. One instance per authenticator keeps
- * them reachable. MSAL4J documents the application as thread safe and meant to be reused.
- *
- *
The instance is rebuilt when the client id, secret or tenant changes, because all three
- * are editable from the admin screen while Fess is running.
- *
- * @return The client application.
- */
- protected ConfidentialClientApplication getClientApplication() {
- final ClientApplicationHolder current = clientApplicationHolder;
- final String key = buildClientApplicationKey();
- if (current != null && current.getKey().equals(key)) {
- return current.getApplication();
- }
- synchronized (this) {
- // The four settings are read again, and the key recomputed, inside the monitor. They
- // are four independent reads of mutable configuration, so a key built on the fast path
- // can mix values from before and after an admin save; publishing an application built
- // from that mixture would leave it in place indefinitely.
- final String currentKey = buildClientApplicationKey();
- final ClientApplicationHolder holder = clientApplicationHolder;
- if (holder != null && holder.getKey().equals(currentKey)) {
- return holder.getApplication();
- }
- final String clientId = getClientId();
- final String clientSecret = getClientSecret();
- final String authority = getAuthorityUrl();
- if (logger.isDebugEnabled()) {
- logger.debug("Building a client application for authority={}", authority);
- }
- try {
- final ConfidentialClientApplication application = ConfidentialClientApplication
- .builder(clientId, com.microsoft.aad.msal4j.ClientCredentialFactory.createFromSecret(clientSecret))
- .authority(authority)
- .build();
- clientApplicationHolder =
- new ClientApplicationHolder(buildClientApplicationKey(clientId, clientSecret, authority), application);
- return application;
- } catch (final Exception e) {
- throw new SsoLoginException("Failed to build an Entra ID client application.", e);
- }
- }
- }
-
- /**
- * Reads the configuration the client application depends on and reduces it to a key.
- *
- * @return The key.
- */
- protected String buildClientApplicationKey() {
- return buildClientApplicationKey(getClientId(), getClientSecret(), getAuthorityUrl());
- }
-
- /**
- * Reduces the configuration the client application depends on to a key.
- *
- * @param clientId The client id.
- * @param clientSecret The client secret.
- * @param authority The authority URL.
- * @return The key.
- */
- protected String buildClientApplicationKey(final String clientId, final String clientSecret, final String authority) {
- // The secret is reduced to a hash so the key can never carry it into a log or a heap dump
- // label; a collision would only mean the application is not rebuilt after a secret change.
- return authority + '\n' + clientId + '\n' + clientSecret.hashCode();
- }
-
- /**
- * A client application and the configuration key it was built from, published together so a
- * reader can never see one without the other.
- */
- protected static final class ClientApplicationHolder {
- private final String key;
- private final ConfidentialClientApplication application;
-
- /**
- * Constructs a holder.
- *
- * @param key The configuration key.
- * @param application The application built from it.
- */
- public ClientApplicationHolder(final String key, final ConfidentialClientApplication application) {
- this.key = key;
- this.application = application;
- }
-
- /**
- * Gets the configuration key.
- *
- * @return The key.
- */
- public String getKey() {
- return key;
- }
-
- /**
- * Gets the client application.
- *
- * @return The application.
- */
- public ConfidentialClientApplication getApplication() {
- return application;
- }
- }
-
- /**
- * Obtains an access token using a refresh token.
- * @param refreshToken The refresh token to use for token acquisition.
- * @return The authentication result containing the access token.
- */
- public IAuthenticationResult getAccessToken(final String refreshToken) {
- final String authority = getAuthorityUrl();
- if (logger.isDebugEnabled()) {
- logger.debug("refreshToken={}, authority={}", maskSecret(refreshToken), authority);
- }
- try {
- final ConfidentialClientApplication app = getClientApplication();
-
- final RefreshTokenParameters parameters =
- RefreshTokenParameters.builder(Collections.singleton(GRAPH_DEFAULT_SCOPE), refreshToken).build();
-
- final IAuthenticationResult result = app.acquireToken(parameters).get(acquisitionTimeout, TimeUnit.MILLISECONDS);
- if (result == null) {
- throw new SsoLoginException("authentication result was null");
- }
- trackAccount(result);
- return result;
- } catch (final Exception e) {
- throw new SsoLoginException("Failed to get a token.", e);
- }
- }
-
- /**
- * Obtains an access token using an authorization code.
- * @param authorizationCode The authorization code received from Entra ID.
- * @param currentUri The current URI for the redirect.
- * @return The authentication result containing the access token.
- */
- protected IAuthenticationResult getAccessToken(final AuthorizationCode authorizationCode, final String currentUri) {
- final String authority = getAuthorityUrl();
- final String authCode = authorizationCode.getValue();
- if (logger.isDebugEnabled()) {
- logger.debug("authCode={}, authority={}, uri={}", maskSecret(authCode), authority, currentUri);
- }
- try {
- final ConfidentialClientApplication app = getClientApplication();
-
- final AuthorizationCodeParameters parameters = AuthorizationCodeParameters.builder(authCode, new URI(currentUri))
- .scopes(Collections.singleton(GRAPH_DEFAULT_SCOPE))
- .build();
-
- final IAuthenticationResult result = app.acquireToken(parameters).get(acquisitionTimeout, TimeUnit.MILLISECONDS);
- if (result == null) {
- throw new SsoLoginException("authentication result was null");
- }
- trackAccount(result);
- return result;
- } catch (final Exception e) {
- throw new SsoLoginException("Failed to get a token.", e);
- }
- }
-
- /**
- * Attempts to refresh tokens silently using the MSAL4J silent authentication flow.
- * @param user The Entra ID user whose tokens need to be refreshed.
- * @return The new authentication result, or null if silent refresh failed.
- */
- public IAuthenticationResult refreshTokenSilently(final EntraIdCredential.EntraIdUser user) {
- try {
- final ConfidentialClientApplication app = getClientApplication();
-
- final SilentParameters parameters =
- SilentParameters.builder(Collections.singleton(GRAPH_DEFAULT_SCOPE), user.getAuthenticationResult().account()).build();
-
- final IAuthenticationResult result = app.acquireTokenSilently(parameters).get(acquisitionTimeout, TimeUnit.MILLISECONDS);
- if (logger.isDebugEnabled()) {
- logger.debug("Silent token acquisition successful");
- }
- trackAccount(result);
- return result;
- } catch (final Exception e) {
- if (logger.isDebugEnabled()) {
- logger.debug("Silent token acquisition failed: {}", e.getMessage());
- }
- return null;
- }
- }
-
- /**
- * Validates that the authentication response matches the authorization code flow.
- * @param oidcResponse The OpenID Connect authentication success response.
- */
- protected void validateAuthRespMatchesCodeFlow(final AuthenticationSuccessResponse oidcResponse) {
- if (oidcResponse.getIDToken() != null || oidcResponse.getAccessToken() != null || oidcResponse.getAuthorizationCode() == null) {
- throw new SsoLoginException("unexpected set of artifacts received");
- }
- }
-
- /**
- * Validates the OAuth2 state parameter.
- * @param session The HTTP session containing stored state data.
- * @param state The state parameter to validate.
- * @return The validated state data.
- */
- protected StateData validateState(final HttpSession session, final String state) {
- if (StringUtil.isNotEmpty(state)) {
- final StateData stateDataInSession = removeStateFromSession(session, state);
- if (stateDataInSession != null) {
- return stateDataInSession;
- }
- }
- throw new SsoStateException("could not validate state");
- }
-
- /**
- * Removes and returns state data from the HTTP session.
- * @param session The HTTP session.
- * @param state The state parameter to remove.
- * @return The removed state data or null if not found.
- */
- protected StateData removeStateFromSession(final HttpSession session, final String state) {
- final Map states = getStateMap(session);
- removeExpiredStates(states);
- final StateData stateData = states.remove(state);
- if (stateData != null && logger.isDebugEnabled()) {
- logger.debug("Restoring state from session: {}", stateData);
- }
- return stateData;
- }
-
- /**
- * Checks if the request contains authentication data from Entra ID.
- * @param request The HTTP servlet request to check.
- * @return True if authentication data is present, false otherwise.
- */
- protected boolean containsAuthenticationData(final HttpServletRequest request) {
- if (logger.isDebugEnabled()) {
- logger.debug("HTTP Method: {}", request.getMethod());
- }
- // The authorization response arrives as a GET in query mode and as a POST in form_post
- // mode; both are accepted because entraid.response.mode selects between them, and a login
- // already in flight when that setting changes still has to complete.
- final String method = request.getMethod();
- if (!"GET".equalsIgnoreCase(method) && !"POST".equalsIgnoreCase(method)) {
- return false;
- }
- final Map params = request.getParameterMap();
- if (logger.isDebugEnabled()) {
- logger.debug("params={}", params.keySet());
- }
- return params.containsKey(ERROR) || params.containsKey(ID_TOKEN) || params.containsKey(CODE);
- }
-
- /**
- * Applies the headers and timeouts every Microsoft Graph request needs.
- *
- * @param request The request to configure.
- * @param accessToken The bearer token to authenticate with.
- * @return The configured request.
- */
- protected CurlRequest createGraphRequest(final CurlRequest request, final String accessToken) {
- return request.header("Authorization", "Bearer " + accessToken)
- .header("Accept", "application/json")
- .timeout(graphConnectTimeout, graphReadTimeout);
- }
-
- /**
- * Sets the connection timeout for Microsoft Graph requests.
- * @param graphConnectTimeout The timeout in milliseconds.
- */
- public void setGraphConnectTimeout(final int graphConnectTimeout) {
- this.graphConnectTimeout = graphConnectTimeout;
- }
-
- /**
- * Sets the read timeout for Microsoft Graph requests.
- * @param graphReadTimeout The timeout in milliseconds.
- */
- public void setGraphReadTimeout(final int graphReadTimeout) {
- this.graphReadTimeout = graphReadTimeout;
- }
-
- /**
- * Updates the user's group and role membership information, walking direct memberships and
- * their parent groups in one pass. This method itself runs synchronously -- {@link
- * #scheduleUpdateMemberOf} is what keeps it off the login thread -- so tests can call it
- * directly.
- *
- * When Microsoft Graph does not answer with a membership list, a re-resolution keeps the
- * memberships it resolved earlier rather than replacing them. A first resolution has none of
- * those to keep, so it writes whatever was collected on top of the configured defaults the
- * lists were seeded with, and marks the user {@link PermissionState#FAILED} rather than
- * leaving the session with no memberships at all.
- *
- * @param user The Entra ID user to update.
- */
- public void updateMemberOf(final EntraIdUser user) {
- // Captured before anything below writes to the user: markResolutionCompleted later in this
- // method changes what user.isResolutionCompleted() answers.
- //
- // Not `user.getGroupNames() == null`, which this used to be: the constructor seeds the
- // configured defaults, so the memberships are never null and every resolution would take
- // the re-resolution path -- keeping the defaults forever instead of the resolved groups.
- final boolean firstResolution = !user.isResolutionCompleted();
- if (logger.isDebugEnabled()) {
- logger.debug("[updateMemberOf] Starting for user: {}", user.getName());
- }
-
- final List groupList = new ArrayList<>();
- final List roleList = new ArrayList<>();
- final List groupIdsForParentLookup = new ArrayList<>();
-
- final List defaultGroups = getDefaultGroupList();
- final List defaultRoles = getDefaultRoleList();
- groupList.addAll(defaultGroups);
- roleList.addAll(defaultRoles);
-
- if (logger.isDebugEnabled()) {
- logger.debug("[updateMemberOf] Default groups: {}, Default roles: {}", defaultGroups, defaultRoles);
- }
-
- // Retrieve direct group/role memberships; group IDs are collected for the parent walk below.
- final boolean resolved = processDirectMemberOf(user, groupList, roleList, groupIdsForParentLookup, GRAPH_V1_URL + "/me/memberOf");
-
- if (logger.isDebugEnabled()) {
- logger.debug("[updateMemberOf] Direct groups retrieved. Total groups: {}, Total roles: {}, Group IDs for parent lookup: {}",
- groupList.size(), roleList.size(), groupIdsForParentLookup.size());
- }
-
- if (!resolved) {
- // Microsoft Graph did not answer with a membership list -- an expired token, a
- // throttled tenant, a revoked permission.
- if (!firstResolution) {
- // Refresh path. Writing what we have would replace the memberships this user
- // logged in with by the configured defaults alone, silently taking away their
- // search permissions until some later call happens to succeed.
- logger.warn("Failed to resolve the Entra ID memberships of {}. Keeping the ones already resolved.", user.getName());
- return;
- }
- // First resolution, so there is nothing to keep. Degrade rather than refuse: a
- // throttled tenant, a Graph outage or a permission that was never granted would
- // otherwise refuse every login in the tenant for as long as the condition lasts. The
- // lists are seeded with the configured defaults before the lookup, so what is written
- // below is always a superset of them, and Task 4 makes the shortfall visible.
- logger.warn("Failed to resolve the Entra ID memberships of {}. Continuing with the memberships"
- + " collected so far and the configured defaults.", user.getName());
- }
-
- // Every direct group is still walked after one of them fails: a partial parent set is worth
- // more than none, so the failures are collected rather than short-circuited. What does end
- // the walk early is maxConsecutiveGroupLookupFailures answers in a row that Graph did not
- // give -- past that point the tenant is unreachable rather than one group being broken,
- // and continuing only buys one request, one timeout and one stack trace per remaining
- // group. Whatever was collected before that is still applied below.
- boolean parentsResolved = true;
- int walkedCount = 0;
- int consecutiveFailures = 0;
- for (final String groupId : groupIdsForParentLookup) {
- ++walkedCount;
- if (processParentGroup(user, groupList, roleList, groupId)) {
- consecutiveFailures = 0;
- continue;
- }
- parentsResolved = false;
- if (isGraphThrottled()) {
- // A lookup skipped for the backoff never reached Graph: it costs nothing, and the
- // backoff already bounds the tenant. Counting it would end the walk -- and log the
- // WARN below -- on every login for as long as the throttle lasts, for no saving.
- continue;
- }
- if (++consecutiveFailures >= maxConsecutiveGroupLookupFailures) {
- logger.warn(
- "Stopped resolving the nested groups of {} after {} consecutive Microsoft Graph failures."
- + " {} of {} direct groups were not walked.",
- user.getName(), consecutiveFailures, groupIdsForParentLookup.size() - walkedCount, groupIdsForParentLookup.size());
- break;
- }
- }
-
- user.setGroups(groupList.stream().distinct().toArray(String[]::new));
- user.setRoles(roleList.stream().distinct().toArray(String[]::new));
- user.resetPermissions();
-
- // No firstResolution guard: the only case that must not touch the state -- a re-resolution
- // whose direct lookup Graph did not answer -- returned early above. What reaches here is
- // either a resolution that succeeded, first or not, or one that fell short in the direct
- // lookup or in the parent group walk. Guarding this would pin a stale FAILED on a user
- // whose token renewal has since resolved their groups.
- //
- // The walk counts as much as the direct lookup: a Graph backoff is recorded on this
- // authenticator for the whole tenant, so one user's 429 skips every parent lookup for up
- // to MAX_GRAPH_THROTTLE_SECONDS while the direct lookup keeps answering. The users
- // resolved in that window hold their direct groups alone -- fewer permissions than they
- // should have, which is what FAILED is there to say.
- user.setPermissionState(resolved && parentsResolved ? PermissionState.RESOLVED : PermissionState.FAILED);
-
- // Every path that reaches here has written the memberships, so the next resolution is a
- // re-resolution and must keep them rather than fall back to the defaults alone.
- user.markResolutionCompleted();
-
- ComponentUtil.getActivityHelper().permissionChanged(OptionalThing.of(new FessUserBean(user)));
-
- if (logger.isDebugEnabled()) {
- logger.debug("[updateMemberOf] Completed for user: {}", user.getName());
- }
- }
-
- /**
- * Puts the configured default groups and roles on a user that has just been constructed, so
- * that they apply for the whole window before {@link #updateMemberOf} lands rather than only
- * after it.
- *
- * Nothing here reaches Microsoft Graph -- {@code entraid.default.groups} and
- * {@code entraid.default.roles} are static configuration -- so it is safe on the login thread,
- * which is the point: {@code SsoAction} redirects to the search page in the same request that
- * schedules the resolution, and a user holding no groups at all sees a near-empty result set
- * until it completes.
- *
- *
This does not mark the user as resolved: it is a seed, not a resolution, and
- * {@link #updateMemberOf} must still treat the next run as the first one.
- *
- * @param user The Entra ID user to seed.
- */
- public void applyDefaultMemberships(final EntraIdUser user) {
- user.setGroups(getDefaultGroupList().stream().distinct().toArray(String[]::new));
- user.setRoles(getDefaultRoleList().stream().distinct().toArray(String[]::new));
- }
-
- /**
- * Field names from {@code entraid.permission.fields} that Microsoft Graph does not answer with
- * a string. Reported once each, because the alternative is one warning per group per login.
- */
- protected final Set unusablePermissionFields = ConcurrentHashMap.newKeySet();
-
- /**
- * Reads one of the fields named by {@code entraid.permission.fields} out of a Microsoft Graph
- * object as a permission value.
- *
- * Only a string can be one: a permission is matched against the {@code role} field of a
- * document, which holds strings. Graph answers with the type the directory schema gives the
- * field, so naming {@code securityEnabled} or {@code groupTypes} yields a boolean or an array.
- * Casting those threw {@link ClassCastException} out of the middle of the membership loop,
- * where the surrounding {@code catch} turned one mistyped field name into "no groups at all"
- * for every user in the tenant. Skipping the value keeps the rest of the memberships, and the
- * warning says which field is at fault.
- *
- * @param source The Graph object to read from.
- * @param name The configured field name.
- * @return The value, or null when the field is absent or is not a string.
- */
- protected String getPermissionFieldValue(final Map source, final String name) {
- final Object value = source.get(name);
- if (value == null || value instanceof String) {
- return (String) value;
- }
- if (unusablePermissionFields.add(name)) {
- logger.warn("entraid.permission.fields names {}, which Microsoft Graph answers with {} rather than a string. "
- + "It cannot be a permission value and is ignored.", name, value.getClass().getSimpleName());
- }
- return null;
- }
-
- /**
- * Adds a group or role name to the specified list.
- * @param list The list to add the group or role name to.
- * @param value The group or role name value.
- * @param useDomainServices Whether to use domain services for group resolution.
- */
- protected void addGroupOrRoleName(final List list, final String value, final boolean useDomainServices) {
- list.add(value);
- if (useDomainServices && value.indexOf('@') >= 0) {
- final String[] values = value.split("@");
- if (values.length > 1) {
- list.add(values[0]);
- }
- }
- }
-
- /**
- * Processes direct member-of information from Microsoft Graph API without parent group lookup.
- * This method retrieves only direct group memberships and collects their group IDs, which
- * {@link #updateMemberOf} then walks for parent groups in the same pass.
- * @param user The Entra ID user.
- * @param groupList The list to add group names to.
- * @param roleList The list to add role names to.
- * @param groupIdsForParentLookup The list to collect group IDs for later parent lookup.
- * @param url The Microsoft Graph API URL.
- * @return True if Microsoft Graph answered with a membership list, false if it reported an
- * error or could not be read. When this is false the lists hold the configured
- * defaults they were seeded with plus whatever was collected before the failure, and
- * what {@link #updateMemberOf} does with them turns on whether a resolution has
- * completed for this user before: a re-resolution discards them and keeps the
- * memberships it resolved earlier, while a first resolution writes them.
- */
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- if (logger.isDebugEnabled()) {
- logger.debug("[processDirectMemberOf] Fetching direct memberships from URL: {}", url);
- }
- try (CurlResponse response = createGraphRequest(Curl.get(url), user.getAuthenticationResult().accessToken()).execute()) {
- // Before the body, for the same reason as in getMemberGroupIds: a throttled reply is
- // not required to be JSON, and the parser throws CurlException when it is not. The
- // lookup itself is still attempted while throttled -- a login has to try -- but
- // recording the backoff here is what keeps the asynchronous parent group walk from
- // hammering a Graph that already asked us to wait.
- applyGraphThrottle(response);
- final Map contentMap = response.getContent(SearchEngineCurl.jsonParser());
- if (logger.isDebugEnabled()) {
- logger.debug("response={}", contentMap);
- }
- if (contentMap.containsKey("value")) {
- @SuppressWarnings("unchecked")
- final List> memberOfList = (List>) contentMap.get("value");
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- final String[] names = fessConfig.getEntraIdPermissionFields();
- final boolean useDomainServices = fessConfig.isEntraIdUseDomainServices();
- for (final Map memberOf : memberOfList) {
- if (logger.isDebugEnabled()) {
- logger.debug("member={}", memberOf);
- }
- String memberType = (String) memberOf.get("@odata.type");
- if (memberType == null) {
- logger.warn("@odata.type is null: {}", memberOf);
- continue;
- }
- memberType = memberType.toLowerCase(Locale.ENGLISH);
- final String id = (String) memberOf.get("id");
- if (StringUtil.isNotBlank(id)) {
- if (memberType.contains("group")) {
- groupList.add(id);
- // Collect group ID for parent lookup (deferred)
- groupIdsForParentLookup.add(id);
- if (logger.isDebugEnabled()) {
- logger.debug("[processDirectMemberOf] Added group ID: {} (will lookup parent groups later)", id);
- }
- } else if (memberType.contains("role")) {
- roleList.add(id);
- if (logger.isDebugEnabled()) {
- logger.debug("[processDirectMemberOf] Added role ID: {}", id);
- }
- } else {
- if (logger.isDebugEnabled()) {
- logger.debug("[processDirectMemberOf] Unknown @odata.type: {}, treating as group", memberOf);
- }
- groupList.add(id);
- groupIdsForParentLookup.add(id);
- }
- } else {
- logger.warn("id is empty: {}", memberOf);
- }
- for (final String name : names) {
- final String value = getPermissionFieldValue(memberOf, name);
- if (StringUtil.isNotBlank(value)) {
- if (logger.isDebugEnabled()) {
- logger.debug("{} is a member of {}", name, value);
- }
- addGroupOrRoleName(memberType.contains("role") ? roleList : groupList, value, useDomainServices);
- } else if (logger.isDebugEnabled()) {
- logger.debug("{} is empty: {}", name, memberOf);
- }
- }
- }
- final String nextLink = (String) contentMap.get("@odata.nextLink");
- if (StringUtil.isNotBlank(nextLink)) {
- return processDirectMemberOf(user, groupList, roleList, groupIdsForParentLookup, nextLink);
- }
- return true;
- }
- if (contentMap.containsKey("error")) {
- logger.warn("Failed to access groups/roles: {}", contentMap);
- } else {
- logger.warn("Unexpected response while accessing groups/roles: {}", contentMap);
- }
- return false;
- } catch (final IOException | RuntimeException e) {
- // Every unchecked failure has to take this path too, not just curl4j's CurlException:
- // a body whose "value" is not an array of objects throws ClassCastException from the
- // cast above, which used to escape updateMemberOf and the EntraIdUser constructor and
- // land on the generic error page rather than on the controlled outcome the caller
- // chooses. Nothing thrown from inside this method has to propagate to the caller.
- logger.warn("Failed to access groups/roles in Entra ID.", e);
- return false;
- }
- }
-
- /**
- * Runs {@link #updateMemberOf} on a background thread.
- *
- * The Microsoft Graph calls behind it -- the direct membership lookup and the parent group
- * walk, one call per direct group -- used to be split across the login thread and a second
- * scheduled task. One task keeps the login off Graph altogether and writes the groups, the
- * roles and the permission reset once at the end, instead of publishing the direct groups and
- * then overwriting them.
- *
- * @param user The Entra ID user to resolve.
- */
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- TimeoutManager.getInstance().addTimeoutTarget(() -> {
- final long startTime = System.currentTimeMillis();
- try {
- updateMemberOf(user);
- } catch (final Exception e) {
- // A backstop: updateMemberOf contains every Graph failure itself, so reaching here
- // means something unforeseen -- possibly after the memberships were already
- // resolved and written correctly, e.g. a throw from the permissionChanged audit
- // call below them. So this does not claim the resolution itself failed. What it
- // does guard is PENDING: a throw between the setGroups/setRoles write and the
- // setPermissionState write would otherwise leave the user PENDING forever, which
- // is indistinguishable from never having been resolved at all. A re-resolution's
- // state is RESOLVED (or FAILED), not PENDING, so this leaves it untouched.
- logger.warn("Unexpected error while resolving the Entra ID memberships of {} after {}ms.", user.getName(),
- System.currentTimeMillis() - startTime, e);
- if (user.getPermissionState() == PermissionState.PENDING) {
- user.setPermissionState(PermissionState.FAILED);
- }
- }
- }, 0, false);
- }
-
- /**
- * Processes parent group information for nested groups.
- * @param user The Entra ID user.
- * @param groupList The list to add group names to.
- * @param roleList The list to add role names to.
- * @param id The group ID to process.
- * @return True if the walk completed without a Microsoft Graph failure. See
- * {@link #processParentGroup(EntraIdUser, List, List, String, int)}.
- */
- protected boolean processParentGroup(final EntraIdUser user, final List groupList, final List roleList,
- final String id) {
- return processParentGroup(user, groupList, roleList, id, 0);
- }
-
- /**
- * Processes parent group information for nested groups with depth tracking.
- * @param user The Entra ID user.
- * @param groupList The list to add group names to.
- * @param roleList The list to add role names to.
- * @param id The group ID to process.
- * @param depth The current recursion depth.
- * @return True if the walk completed without a Microsoft Graph failure, so that
- * {@link #updateMemberOf} can tell a user who holds all their parent groups from one
- * who silently holds only some of them. The configured depth bound is not a failure --
- * it is where the walk is meant to stop -- and neither is a group that genuinely has
- * no parents.
- */
- protected boolean processParentGroup(final EntraIdUser user, final List groupList, final List roleList, final String id,
- final int depth) {
- if (logger.isDebugEnabled()) {
- logger.debug("[processParentGroup] Processing parent groups for id: {}, depth: {}/{}", id, depth, maxGroupDepth);
- }
- if (depth >= maxGroupDepth) {
- if (logger.isDebugEnabled()) {
- logger.debug("[processParentGroup] Maximum group depth {} reached for group {}", maxGroupDepth, id);
- }
- return true;
- }
- final AtomicBoolean failed = new AtomicBoolean();
- final Pair groupsAndRoles = getParentGroup(user, id, depth, failed);
- Collections.addAll(groupList, groupsAndRoles.getFirst());
- Collections.addAll(roleList, groupsAndRoles.getSecond());
- if (logger.isDebugEnabled()) {
- logger.debug("[processParentGroup] Completed for id: {}, depth: {}, added groups: {}, added roles: {}, failed: {}", id, depth,
- groupsAndRoles.getFirst().length, groupsAndRoles.getSecond().length, failed.get());
- }
- return !failed.get();
- }
-
- /**
- * Retrieves parent group information for the specified group ID with depth tracking, for a
- * caller that has nothing to report a failure to.
- * @param user The Entra ID user.
- * @param id The group ID to get parent information for.
- * @param depth The current recursion depth.
- * @return A pair containing group names and role names.
- */
- protected Pair getParentGroup(final EntraIdUser user, final String id, final int depth) {
- return getParentGroup(user, id, depth, new AtomicBoolean());
- }
-
- /**
- * Retrieves parent group information for the specified group ID with depth tracking.
- *
- * An empty result is not by itself an answer: it is also what a skipped or failed lookup
- * returns. {@code failed} is what tells the two apart, so that a user missing their parent
- * groups is not reported as fully resolved.
- *
- * @param user The Entra ID user.
- * @param id The group ID to get parent information for.
- * @param depth The current recursion depth.
- * @param failed Set to true when Microsoft Graph could not be asked, or answered with a
- * failure, anywhere in this walk. Never cleared, so one flag can be passed down the
- * recursion and read once at the top.
- * @return A pair containing group names and role names.
- */
- protected Pair getParentGroup(final EntraIdUser user, final String id, final int depth,
- final AtomicBoolean failed) {
- if (logger.isDebugEnabled()) {
- logger.debug("[getParentGroup] Getting parent groups for id: {}, depth: {}", id, depth);
- }
- if (depth >= maxGroupDepth) {
- if (logger.isDebugEnabled()) {
- logger.debug("[getParentGroup] Maximum group depth {} reached for group {}", maxGroupDepth, id);
- }
- return new Pair<>(StringUtil.EMPTY_STRINGS, StringUtil.EMPTY_STRINGS);
- }
- // Check if cached
- final String cacheKey = buildGroupCacheKey(id);
- final Pair cachedResult = groupCache.getIfPresent(cacheKey);
- if (cachedResult != null) {
- if (logger.isDebugEnabled()) {
- logger.debug("[getParentGroup] Cache HIT for id: {}, groups: {}, roles: {}", id, cachedResult.getFirst().length,
- cachedResult.getSecond().length);
- }
- return cachedResult;
- }
- if (logger.isDebugEnabled()) {
- logger.debug("[getParentGroup] Cache MISS for id: {}, fetching from API", id);
- }
- if (isGraphThrottled()) {
- // Microsoft Graph asked us to back off, so walking it would fail once per group on
- // every login until the tenant recovered. The empty pair is deliberately not written
- // to groupCache: caching it would keep the parent group permissions away for the whole
- // cache TTL even once the throttle had lapsed, which is what #3223 removed.
- //
- // Skipped, not answered: the backoff is recorded for the whole tenant, so without this
- // flag one user's 429 would report every login in the next hour as fully resolved
- // while none of them held a single parent group.
- failed.set(true);
- if (logger.isDebugEnabled()) {
- logger.debug("[getParentGroup] Skipping the lookup for id {} while Microsoft Graph is throttling.", id);
- }
- return new Pair<>(StringUtil.EMPTY_STRINGS, StringUtil.EMPTY_STRINGS);
- }
- try {
- return groupCache.get(cacheKey, () -> loadParentGroup(user, id, depth, failed));
- } catch (final ExecutionException | UncheckedExecutionException e) {
- failed.set(true);
- // A loader that throws leaves nothing in the cache, which is the point: a throttled or
- // briefly unreachable Graph must not pin an empty result for the whole cache TTL.
- // UncheckedExecutionException matters because the Graph JSON parser throws
- // CurlException, a RuntimeException, on a non-JSON error body.
- if (isGraphThrottled()) {
- // The reason is already stated by the single WARN that set the throttle; a stack
- // trace per group would bury it.
- logger.warn("Failed to process group cache for id {} while Microsoft Graph is throttling: {}", id, e.getMessage());
- } else {
- logger.warn("Failed to process group cache for id: {}", id, e);
- }
- return new Pair<>(StringUtil.EMPTY_STRINGS, StringUtil.EMPTY_STRINGS);
- }
- }
-
- /**
- * The key {@link #groupCache} stores a parent group lookup under.
- *
- * What is cached is not the raw Graph answer: {@link #processGroup} has already turned it
- * into the permission values that {@code entraid.permission.fields} selects. Keying it by
- * group id alone meant a change to that setting was ignored for the rest of the cache TTL, and
- * only for nested groups -- direct memberships are read from Graph on every login, so they
- * picked the new setting up immediately. A user was then granted permissions derived from the
- * new setting for the groups they belong to directly and from the old one for the groups above
- * them.
- *
- *
Narrowing the setting is the case that matters. The documentation warns that
- * {@code displayName} is neither domain-qualified nor unique and can match documents it should
- * not; removing it once that has happened left every nested group granting it for up to ten
- * more minutes.
- *
- * @param id The group id.
- * @return The cache key.
- */
- protected String buildGroupCacheKey(final String id) {
- return id + '\n' + String.join(",", ComponentUtil.getFessConfig().getEntraIdPermissionFields());
- }
-
- /**
- * Returns whether Microsoft Graph is still inside the backoff a throttled response asked for.
- *
- * @return True while the parent group walk has to be skipped.
- */
- protected boolean isGraphThrottled() {
- final long until = graphThrottledUntil;
- // The clock is read only once a throttle has actually been recorded, so the ordinary path
- // does not depend on the system helper at all.
- return until > 0L && ComponentUtil.getSystemHelper().getCurrentTimeAsLong() < until;
- }
-
- /**
- * Records the backoff a throttled Microsoft Graph response asked for, if it is one.
- *
- *
Nothing is cached in response to it. 15.7 cached an empty result for the cache TTL, and
- * #3223 removed that because it silently took the parent group permissions away for ten
- * minutes. An explicit backoff does the job the negative cache was reaching for -- the next
- * logins skip the walk instead of re-issuing one failing request, and one stack trace, per
- * group -- without outliving the condition that caused it.
- *
- * @param response The response to inspect.
- */
- protected void applyGraphThrottle(final CurlResponse response) {
- // curl4j does not throw on a non-2xx response, it hands back the error stream, so the
- // status code is the only place a 429 is visible.
- final int statusCode = response.getHttpStatusCode();
- if (statusCode != HTTP_TOO_MANY_REQUESTS && statusCode != HTTP_SERVICE_UNAVAILABLE) {
- return;
- }
- final long seconds = parseRetryAfterSeconds(response.getHeaderValue("Retry-After"));
- final long until = ComponentUtil.getSystemHelper().getCurrentTimeAsLong() + seconds * 1000L;
- if (until > graphThrottledUntil) {
- graphThrottledUntil = until;
- // One line per throttling episode: every group after this one short-circuits in
- // getParentGroup without reaching Graph, so nothing repeats it per group.
- logger.warn("Microsoft Graph returned {} for a group membership lookup."
- + " Nested groups are not resolved for the next {} seconds.", statusCode, seconds);
- }
- }
-
- /**
- * Reads a {@code Retry-After} header as a number of seconds.
- *
- * @param value The header value, which may be null.
- * @return The backoff in seconds, always positive and bounded by
- * {@link #MAX_GRAPH_THROTTLE_SECONDS}.
- */
- protected long parseRetryAfterSeconds(final String value) {
- if (StringUtil.isNotBlank(value)) {
- try {
- final long seconds = Long.parseLong(value.trim());
- if (seconds > 0L) {
- return Math.min(seconds, MAX_GRAPH_THROTTLE_SECONDS);
- }
- } catch (final NumberFormatException e) {
- // RFC 9110 also allows an HTTP-date here. Microsoft Graph sends delay-seconds, so
- // rather than parse a format we never see, fall back to the default.
- if (logger.isDebugEnabled()) {
- logger.debug("Retry-After is not a number of seconds: {}", value);
- }
- }
- }
- return DEFAULT_GRAPH_THROTTLE_SECONDS;
- }
-
- /**
- * Walks the parent groups of the specified group. A failure of the {@code getMemberGroups}
- * lookup itself is thrown rather than turned into an empty result, so a caller that caches
- * this never stores a transient failure. A failure of one of the per-group reads underneath it
- * cannot be thrown -- the groups that did answer are worth keeping -- so it is recorded in
- * {@code failed} instead.
- *
- * @param user The Entra ID user.
- * @param id The group ID to get parent information for.
- * @param depth The current recursion depth.
- * @param failed Set to true when a lookup underneath this one could not be made or failed.
- * See {@link #getParentGroup(EntraIdUser, String, int, AtomicBoolean)}.
- * @return A pair containing group names and role names.
- * @throws IOException If Microsoft Graph could not be reached or returned an error.
- */
- protected Pair loadParentGroup(final EntraIdUser user, final String id, final int depth, final AtomicBoolean failed)
- throws IOException {
- if (logger.isDebugEnabled()) {
- logger.debug("[getParentGroup] Loading parent groups for id: {}", id);
- }
- final List groupList = new ArrayList<>();
- final List roleList = new ArrayList<>();
- for (final String value : getMemberGroupIds(user, id)) {
- if (logger.isDebugEnabled()) {
- logger.debug("[getParentGroup] Processing parent group id: {} for group: {}", value, id);
- }
- if (!processGroup(user, groupList, roleList, value)) {
- // The group is dropped entirely -- processGroup adds nothing at all when the read
- // fails -- so the user ends up without a parent group they are a member of.
- failed.set(true);
- }
- if (!groupList.contains(value) && !roleList.contains(value)) {
- if (logger.isDebugEnabled()) {
- logger.debug("[getParentGroup] Recursively getting parent groups for: {}", value);
- }
- final Pair groupsAndRoles = getParentGroup(user, value, depth + 1, failed);
- Collections.addAll(groupList, groupsAndRoles.getFirst());
- Collections.addAll(roleList, groupsAndRoles.getSecond());
- }
- }
- final Pair result =
- new Pair<>(groupList.stream().distinct().toArray(String[]::new), roleList.stream().distinct().toArray(String[]::new));
- if (logger.isDebugEnabled()) {
- logger.debug("[getParentGroup] Result for id {}: {} groups, {} roles", id, result.getFirst().length, result.getSecond().length);
- }
- return result;
- }
-
- /**
- * Asks Microsoft Graph which groups the specified group is a member of.
- *
- * Two error codes are real answers rather than failures and come back as an empty array so
- * that the caller can cache them: {@code Request_ResourceNotFound}, because the group does not
- * exist, and {@code Authorization_RequestDenied}, because a Graph permission that was never
- * granted will not appear within the cache TTL -- throwing on it left nothing cached and made
- * every login re-issue one failing request, and one stack trace, per group. Everything else is
- * thrown, so a transient failure is never mistaken for "this group has no parents".
- *
- *
A throttled reply is recorded by {@link #applyGraphThrottle} before the body is looked
- * at, so the groups after this one skip the walk instead of each producing their own failure.
- *
- * @param user The Entra ID user.
- * @param id The group ID to get parent information for.
- * @return The parent group IDs, never null.
- * @throws IOException If Microsoft Graph could not be reached or returned an error.
- */
- protected String[] getMemberGroupIds(final EntraIdUser user, final String id) throws IOException {
- return getMemberGroupIds(user, id, GRAPH_V1_URL + "/groups/" + id + "/getMemberGroups");
- }
-
- /**
- * Asks Microsoft Graph, at the specified URL, which groups the specified group is a member of.
- * The URL is a parameter so that this, like {@link #processDirectMemberOf} and
- * {@link #processGroup}, can be pointed at a stub rather than at Microsoft Graph.
- *
- * @param user The Entra ID user.
- * @param id The group ID to get parent information for.
- * @param url The Microsoft Graph URL to post the request to.
- * @return The parent group IDs, never null.
- * @throws IOException If Microsoft Graph could not be reached or returned an error.
- */
- protected String[] getMemberGroupIds(final EntraIdUser user, final String id, final String url) throws IOException {
- if (logger.isDebugEnabled()) {
- logger.debug("[getParentGroup] Calling API: {}", url);
- }
- try (CurlResponse response =
- createGraphRequest(Curl.post(url), user.getAuthenticationResult().accessToken()).header("Content-type", "application/json")
- .body("{\"securityEnabledOnly\":false}")
- .execute()) {
- // Before the body: a throttled reply is not required to be JSON, and the parser throws
- // CurlException when it is not.
- applyGraphThrottle(response);
- final Map contentMap = response.getContent(SearchEngineCurl.jsonParser());
- if (logger.isDebugEnabled()) {
- logger.debug("[getParentGroup] Response for id {}: {}", id, contentMap);
- }
- return toMemberGroupIds(contentMap, id);
- }
- }
-
- /**
- * Classifies a {@code getMemberGroups} response body. See {@link #getMemberGroupIds} for which
- * error codes count as an answer and which are failures.
- *
- * @param contentMap The parsed response body.
- * @param id The group ID the response is for.
- * @return The parent group IDs, never null.
- * @throws IOException If the body reports a failure rather than an answer.
- */
- protected String[] toMemberGroupIds(final Map contentMap, final String id) throws IOException {
- if (contentMap.containsKey("value")) {
- final String[] values = DocumentUtil.getValue(contentMap, "value", String[].class);
- return values != null ? values : StringUtil.EMPTY_STRINGS;
- }
- if (contentMap.containsKey("error")) {
- if (contentMap.get("error") instanceof final Map, ?> errorMap) {
- final Object code = errorMap.get("code");
- if ("Request_ResourceNotFound".equals(code)) {
- if (logger.isDebugEnabled()) {
- logger.debug("[getParentGroup] Resource not found for id {}: {}", id, contentMap);
- }
- return StringUtil.EMPTY_STRINGS;
- }
- if (PERMISSION_DENIED_ERROR_CODE.equals(code)) {
- logger.warn("Not allowed to read the parent groups of {}. Grant the Entra ID application"
- + " GroupMember.Read.All to resolve nested groups. {}", id, contentMap);
- return StringUtil.EMPTY_STRINGS;
- }
- }
- throw new IOException("Failed to access parent groups for id " + id + ": " + contentMap);
- }
- return StringUtil.EMPTY_STRINGS;
- }
-
- /**
- * Processes individual group information.
- * @param user The Entra ID user.
- * @param groupList The list to add group names to.
- * @param roleList The list to add role names to.
- * @param id The group ID to process.
- * @return True if Microsoft Graph could be read. See
- * {@link #processGroup(EntraIdUser, List, List, String, String)}.
- */
- protected boolean processGroup(final EntraIdUser user, final List groupList, final List roleList, final String id) {
- return processGroup(user, groupList, roleList, id, GRAPH_V1_URL + "/groups/" + id);
- }
-
- /**
- * Processes individual group information read from the specified URL. The URL is a parameter
- * so that this, like {@link #processDirectMemberOf}, can be pointed at a stub rather than at
- * Microsoft Graph.
- *
- * @param user The Entra ID user.
- * @param groupList The list to add group names to.
- * @param roleList The list to add role names to.
- * @param id The group ID to process.
- * @param url The Microsoft Graph URL to read the group from.
- * @return True if Microsoft Graph could be read. False means the group was dropped altogether
- * -- nothing is added on that path -- so the caller has to know rather than take the
- * shorter list for the answer. A body that reports an error is still a read: the group
- * id is kept, only the names configured by {@code entraid.permission.fields} are not.
- */
- protected boolean processGroup(final EntraIdUser user, final List groupList, final List roleList, final String id,
- final String url) {
- if (logger.isDebugEnabled()) {
- logger.debug("[processGroup] Processing group info for id: {} from url: {}", id, url);
- }
- try (CurlResponse response = createGraphRequest(Curl.get(url), user.getAuthenticationResult().accessToken()).execute()) {
- // Before the body, for the same reason as in getMemberGroupIds and
- // processDirectMemberOf: a throttled reply is not required to be JSON, and the parser
- // throws CurlException when it is not. This was the one Graph call in the class that
- // did not record the backoff, and it is the one most likely to meet a 429 first: the
- // parent group walk calls it once per member id, whereas getMemberGroupIds is called
- // once per group. Worse, a 429 whose body *is* JSON leaves the walk believing it had
- // an answer -- groupList.add(id) below runs on every non-throwing path -- so nothing
- // else on that path ever reached Graph to notice the throttling.
- applyGraphThrottle(response);
- final Map contentMap = response.getContent(SearchEngineCurl.jsonParser());
- if (logger.isDebugEnabled()) {
- logger.debug("[processGroup] Response for id {}: {}", id, contentMap);
- }
- groupList.add(id);
- if (contentMap.containsKey("error")) {
- logger.warn("Failed to access group info: {}", contentMap);
- } else {
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- final String[] names = fessConfig.getEntraIdPermissionFields();
- final int initialSize = groupList.size();
- for (final String name : names) {
- final String value = getPermissionFieldValue(contentMap, name);
- if (StringUtil.isNotBlank(value)) {
- groupList.add(value);
- if (logger.isDebugEnabled()) {
- logger.debug("[processGroup] Added {} value: {} for group id: {}", name, value, id);
- }
- } else if (logger.isDebugEnabled()) {
- logger.debug("[processGroup] {} is empty for group id: {}", name, id);
- }
- }
- if (logger.isDebugEnabled()) {
- logger.debug("[processGroup] Completed for id: {}, added {} entries", id, groupList.size() - initialSize);
- }
- }
- return true;
- } catch (final IOException | CurlException e) {
- // See processDirectMemberOf: curl4j's transport failure is the unchecked CurlException.
- logger.warn("Failed to access groups/roles in Entra ID for id: {}", id, e);
- return false;
- }
- }
-
- /**
- * Reads an Entra ID setting, preferring the {@code entraid.*} key and falling back to the
- * legacy {@code aad.*} key.
- *
- * {@code getSystemProperty} only substitutes the default when a key is absent, so a key that
- * is present but empty would otherwise arrive at the caller as {@code ""}. Both keys are
- * therefore tested with {@link StringUtil#isBlank(String)} and the default is returned when
- * neither holds a value.
- *
- * @param key The {@code entraid.*} configuration key.
- * @param legacyKey The legacy {@code aad.*} configuration key.
- * @param defaultValue The value to use when neither key holds a value.
- * @return The configured value, or defaultValue.
- */
- protected String getEntraIdProperty(final String key, final String legacyKey, final String defaultValue) {
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- final String value = fessConfig.getSystemProperty(key);
- if (StringUtil.isNotBlank(value)) {
- return value;
- }
- final String legacyValue = fessConfig.getSystemProperty(legacyKey);
- return StringUtil.isBlank(legacyValue) ? defaultValue : legacyValue;
- }
-
- /**
- * Reads a comma-separated Entra ID setting as a list of trimmed, non-blank values.
- *
- * @param key The {@code entraid.*} configuration key.
- * @param legacyKey The legacy {@code aad.*} configuration key.
- * @return The configured values, or an empty list when neither key holds a value.
- */
- protected List getDefaultList(final String key, final String legacyKey) {
- final String value = getEntraIdProperty(key, legacyKey, StringUtil.EMPTY);
- if (StringUtil.isBlank(value)) {
- return Collections.emptyList();
- }
- return split(value, ",").get(stream -> stream.filter(StringUtil::isNotBlank).map(String::trim).collect(Collectors.toList()));
- }
-
- /**
- * Gets the default group list for users.
- * Uses new entraid.default.groups key with fallback to legacy aad.default.groups.
- * @return The default group list.
- */
- protected List getDefaultGroupList() {
- return getDefaultList(ENTRAID_DEFAULT_GROUPS, AAD_DEFAULT_GROUPS);
- }
-
- /**
- * Gets the default role list for users.
- * Uses new entraid.default.roles key with fallback to legacy aad.default.roles.
- * @return The default role list.
- */
- protected List getDefaultRoleList() {
- return getDefaultList(ENTRAID_DEFAULT_ROLES, AAD_DEFAULT_ROLES);
- }
-
- /**
- * Represents state data stored during the OAuth2 authentication flow.
- */
- protected static class StateData {
- private final String nonce;
- private final long expiration;
-
- /**
- * Constructs StateData with nonce and expiration.
- * @param nonce The nonce value.
- * @param expiration The expiration timestamp.
- */
- public StateData(final String nonce, final long expiration) {
- this.nonce = nonce;
- this.expiration = expiration;
- }
-
- /**
- * Gets the nonce value.
- * @return The nonce.
- */
- public String getNonce() {
- return nonce;
- }
-
- /**
- * Gets the expiration timestamp.
- * @return The expiration timestamp.
- */
- public long getExpiration() {
- return expiration;
- }
-
- @Override
- public String toString() {
- return "StateData [nonce=" + nonce + ", expiration=" + expiration + "]";
- }
- }
-
- /**
- * Gets the Entra ID client ID from configuration.
- * Uses new entraid.client.id key with fallback to legacy aad.client.id.
- * @return The client ID.
- */
- protected String getClientId() {
- return getEntraIdProperty(ENTRAID_CLIENT_ID, AAD_CLIENT_ID, StringUtil.EMPTY);
- }
-
- /**
- * Gets the Entra ID client secret from configuration.
- * Uses new entraid.client.secret key with fallback to legacy aad.client.secret.
- * @return The client secret.
- */
- protected String getClientSecret() {
- return getEntraIdProperty(ENTRAID_CLIENT_SECRET, AAD_CLIENT_SECRET, StringUtil.EMPTY);
- }
-
- /**
- * Gets the Entra ID tenant ID from configuration.
- * Uses new entraid.tenant key with fallback to legacy aad.tenant.
- * @return The tenant ID.
- */
- protected String getTenant() {
- return getEntraIdProperty(ENTRAID_TENANT, AAD_TENANT, StringUtil.EMPTY);
- }
-
- /**
- * Gets the Entra ID authority URL from configuration.
- * Uses new entraid.authority key with fallback to legacy aad.authority.
- * @return The authority URL.
- */
- protected String getAuthority() {
- return getEntraIdProperty(ENTRAID_AUTHORITY, AAD_AUTHORITY, DEFAULT_AUTHORITY);
- }
-
- /**
- * Builds the tenant's authority URL, the prefix every Entra ID endpoint is hung off.
- *
- * The authority and the tenant are joined with exactly one {@code /}. This used to be a
- * raw concatenation, and the only guard on {@link #getAuthority()} maps a blank value
- * onto {@link #DEFAULT_AUTHORITY}, which already carries a trailing slash -- a non-blank value
- * without one was passed through untouched. So
- * {@code entraid.authority=https://login.microsoftonline.com} plus
- * {@code entraid.tenant=contoso.onmicrosoft.com} produced
- * {@code https://login.microsoftonline.comcontoso.onmicrosoft.com/}: the host and the tenant
- * fuse into a single bogus hostname, the browser gets NXDOMAIN, and because the authorization
- * URL is only logged at debug level nothing points back at the setting.
- * {@code https://login.microsoftonline.com} is how the endpoint is written wherever it is
- * documented, so omitting the trailing slash is the expected mistake rather than an exotic one.
- *
- *
A tenant that already starts with a slash joins correctly against a slashless authority,
- * so the separator is inserted only when neither side supplies one and a doubled separator is
- * collapsed; a naive "always append a slash to the authority" fix would break that working
- * configuration. Nothing is lowercased or trimmed, and the value still carries at least one
- * path segment, which msal4j's {@code Authority.detectAuthorityType} requires.
- *
- * @return The authority URL, with a trailing slash.
- */
- protected String getAuthorityUrl() {
- final String authority = getAuthority();
- final String tenant = getTenant();
- if (StringUtil.isEmpty(tenant)) {
- // Left byte-identical to what a blank tenant produced before. validateConfiguration
- // refuses one on the login path, and the doubled slash it leaves behind is quoted in
- // that method's own javadoc as the symptom an unconfigured server shows.
- return authority + "/";
- }
- final String base = authority.endsWith("/") ? authority : authority + "/";
- return base + (tenant.startsWith("/") ? tenant.substring(1) : tenant) + "/";
- }
-
- /**
- * Gets the state time-to-live from configuration.
- * Uses new entraid.state.ttl key with fallback to legacy aad.state.ttl.
- * @return The state TTL in seconds. removeExpiredStates compares it against an elapsed time
- * that has already been divided by 1000.
- */
- protected long getStateTtl() {
- final String value = getEntraIdProperty(ENTRAID_STATE_TTL, AAD_STATE_TTL, DEFAULT_STATE_TTL);
- final long ttl;
- try {
- ttl = Long.parseLong(value.trim());
- } catch (final NumberFormatException e) {
- logger.warn("Invalid {}: {}. Using {} seconds.", ENTRAID_STATE_TTL, value, DEFAULT_STATE_TTL);
- return Long.parseLong(DEFAULT_STATE_TTL);
- }
- if (ttl <= 0L) {
- // removeExpiredStates drops a state once (now - created) / 1000 exceeds this value, so
- // a non-positive TTL expires every login attempt before the user can finish signing in
- // at Microsoft. The callback then reports "could not validate state", which names
- // neither this setting nor the reason, and no login on the server can ever succeed.
- logger.warn("Invalid {}: {}. A login cannot outlive a state that expires immediately. Using {} seconds.", ENTRAID_STATE_TTL,
- value, DEFAULT_STATE_TTL);
- return Long.parseLong(DEFAULT_STATE_TTL);
- }
- return ttl;
- }
-
- /**
- * Gets the reply URL for Entra ID authentication.
- * Uses new entraid.reply.url key with fallback to legacy aad.reply.url.
- * @param request The HTTP servlet request.
- * @return The reply URL.
- */
- protected String getReplyUrl(final HttpServletRequest request) {
- final String value = getEntraIdProperty(ENTRAID_REPLY_URL, AAD_REPLY_URL, StringUtil.EMPTY);
- return StringUtil.isNotBlank(value) ? value : request.getRequestURL().toString();
- }
-
- /**
- * Gets the OAuth2 response mode to ask the authorization endpoint for.
- *
- *
Defaults to {@code query}. Fess ships {@code tomcat.sameSiteCookies = lax}, and a Lax
- * cookie is not sent on the cross-site POST that {@code form_post} produces, so a form_post
- * callback arrives without JSESSIONID and the login loops. A deployment that sets
- * {@code tomcat.sameSiteCookies = none} can select {@code form_post} to keep the
- * authorization code out of the callback URL, and therefore out of browser history and any
- * front-end proxy log.
- *
- * @return Either {@code query} or {@code form_post}.
- */
- protected String getResponseMode() {
- final String value = getEntraIdProperty(ENTRAID_RESPONSE_MODE, AAD_RESPONSE_MODE, RESPONSE_MODE_QUERY).trim();
- if (RESPONSE_MODE_QUERY.equals(value) || RESPONSE_MODE_FORM_POST.equals(value)) {
- return value;
- }
- logger.warn("Invalid {}: {}. Using {}.", ENTRAID_RESPONSE_MODE, value, RESPONSE_MODE_QUERY);
- return RESPONSE_MODE_QUERY;
- }
-
- @Override
- public void resolveCredential(final LoginCredentialResolver resolver) {
- resolver.resolve(EntraIdCredential.class, credential -> OptionalEntity.of(credential.getUser()));
- }
-
- /**
- * Sets the token acquisition timeout.
- * @param acquisitionTimeout The timeout in milliseconds.
- */
- public void setAcquisitionTimeout(final long acquisitionTimeout) {
- this.acquisitionTimeout = acquisitionTimeout;
- }
-
- /**
- * Sets the group cache expiry time.
- * @param groupCacheExpiry The cache expiry time in seconds.
- */
- public void setGroupCacheExpiry(final long groupCacheExpiry) {
- this.groupCacheExpiry = groupCacheExpiry;
- }
-
- /**
- * Sets the maximum number of groups kept in the parent group cache.
- * @param maxGroupCacheSize The maximum number of cached groups.
- */
- public void setMaxGroupCacheSize(final int maxGroupCacheSize) {
- this.maxGroupCacheSize = maxGroupCacheSize;
- }
-
- /**
- * Sets the maximum group depth for nested group processing.
- * @param maxGroupDepth The maximum depth for nested groups.
- */
- public void setMaxGroupDepth(final int maxGroupDepth) {
- this.maxGroupDepth = maxGroupDepth;
- }
-
- /**
- * Sets how many consecutive unanswered parent group lookups end the walk.
- * @param maxConsecutiveGroupLookupFailures The maximum number of consecutive failures.
- */
- public void setMaxConsecutiveGroupLookupFailures(final int maxConsecutiveGroupLookupFailures) {
- this.maxConsecutiveGroupLookupFailures = maxConsecutiveGroupLookupFailures;
- }
-
- @Override
- public String logout(final FessUserBean user) {
- // The client application is shared for the whole server so that its token cache survives
- // between a login and its refresh. MSAL4J's TokenCache is a set of unbounded LinkedHashMaps
- // with no eviction, and removeAccount() is the only way anything leaves it, so a user who
- // logs out has to be dropped explicitly or they stay resident until the JVM restarts.
- if (user.getFessUser() instanceof final EntraIdUser entraIdUser) {
- final IAuthenticationResult authResult = entraIdUser.getAuthenticationResult();
- if (authResult != null && authResult.account() != null) {
- removeAccount(authResult.account());
- }
- }
- // Null keeps the existing behaviour: Fess does not sign the user out at Entra ID.
- return null;
- }
-
- /**
- * Records that an acquisition put tokens in the shared cache, and evicts the accounts that
- * pushes past {@link #maxCachedAccounts}.
- *
- *
The eviction runs outside the monitor: {@link #removeAccount} joins on MSAL4J's future,
- * and holding the map while it does that would put every other acquisition behind it.
- *
- * @param result The acquisition result, which may be null.
- */
- protected void trackAccount(final IAuthenticationResult result) {
- if (result == null || result.account() == null || StringUtil.isBlank(result.account().homeAccountId())) {
- return;
- }
- final List evicted = new ArrayList<>();
- synchronized (cachedAccounts) {
- cachedAccounts.put(result.account().homeAccountId(), result.account());
- while (cachedAccounts.size() > maxCachedAccounts) {
- final Map.Entry eldest = cachedAccounts.entrySet().iterator().next();
- cachedAccounts.remove(eldest.getKey());
- evicted.add(eldest.getValue());
- }
- }
- if (!evicted.isEmpty()) {
- logger.warn("The Entra ID token cache reached {} accounts. Evicting {} that went longest without acquiring a token.",
- maxCachedAccounts, evicted.size());
- evicted.forEach(this::removeAccount);
- }
- }
-
- /**
- * Sets the maximum number of accounts kept in the shared application's token cache.
- * @param maxCachedAccounts The maximum number of accounts.
- */
- public void setMaxCachedAccounts(final int maxCachedAccounts) {
- this.maxCachedAccounts = maxCachedAccounts;
- }
-
- /**
- * Drops an account's tokens from the shared client application's cache.
- *
- * @param account The account to evict.
- */
- protected void removeAccount(final IAccount account) {
- // Unconditional, and ahead of the join below: an eviction has already taken the entry out,
- // and a logout has to take it out whether or not MSAL4J manages to prune its own cache.
- synchronized (cachedAccounts) {
- cachedAccounts.remove(account.homeAccountId());
- }
- try {
- getClientApplication().removeAccount(account).join();
- if (logger.isDebugEnabled()) {
- logger.debug("Removed an account from the token cache.");
- }
- } catch (final Exception e) {
- // Logging out must not fail because the cache could not be pruned.
- logger.warn("Failed to remove an account from the Entra ID token cache.", e);
- }
- }
-
- /**
- * Kept only so an out-of-tree {@code fess_sso+entraidAuthenticator.xml} that still sets this
- * property keeps loading. The value is ignored.
- *
- * The v1.0 endpoint is not supported. msal4j hardcodes {@code oauth2/v2.0/token} as the
- * token endpoint of an AAD authority and offers no v1 alternative, so an authorization code
- * minted at {@code /oauth2/authorize} could never be redeemed -- the v1.0 branch this used to
- * select produced a login that always failed.
- *
- * @param useV2Endpoint Ignored.
- * @deprecated The v1.0 endpoint is unsupported; the authorization request is always v2.0.
- */
- @Deprecated
- public void setUseV2Endpoint(final boolean useV2Endpoint) {
- if (!useV2Endpoint) {
- logger.warn("useV2Endpoint=false is ignored. The Entra ID v1.0 endpoint is not supported:"
- + " msal4j only redeems authorization codes at the v2.0 token endpoint.");
- }
- }
-}
diff --git a/src/main/java/org/codelibs/fess/sso/oic/OpenIdConnectAuthenticator.java b/src/main/java/org/codelibs/fess/sso/oic/OpenIdConnectAuthenticator.java
deleted file mode 100644
index ad3dad266..000000000
--- a/src/main/java/org/codelibs/fess/sso/oic/OpenIdConnectAuthenticator.java
+++ /dev/null
@@ -1,458 +0,0 @@
-/*
- * Copyright 2012-2025 CodeLibs Project and the Others.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
- * either express or implied. See the License for the specific language
- * governing permissions and limitations under the License.
- */
-package org.codelibs.fess.sso.oic;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.UUID;
-
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
-import org.codelibs.core.lang.StringUtil;
-import org.codelibs.core.misc.DynamicProperties;
-import org.codelibs.fess.app.web.base.login.ActionResponseCredential;
-import org.codelibs.fess.app.web.base.login.FessLoginAssist.LoginCredentialResolver;
-import org.codelibs.fess.app.web.base.login.OpenIdConnectCredential;
-import org.codelibs.fess.crawler.Constants;
-import org.codelibs.fess.sso.SsoAuthenticator;
-import org.codelibs.fess.util.ComponentUtil;
-import org.dbflute.optional.OptionalEntity;
-import org.lastaflute.web.login.credential.LoginCredential;
-import org.lastaflute.web.response.HtmlResponse;
-import org.lastaflute.web.util.LaRequestUtil;
-
-import com.google.api.client.auth.oauth2.AuthorizationCodeRequestUrl;
-import com.google.api.client.auth.oauth2.AuthorizationCodeTokenRequest;
-import com.google.api.client.auth.oauth2.TokenResponse;
-import com.google.api.client.http.GenericUrl;
-import com.google.api.client.http.HttpTransport;
-import com.google.api.client.http.javanet.NetHttpTransport;
-import com.google.api.client.json.JsonFactory;
-import com.google.api.client.json.JsonParser;
-import com.google.api.client.json.JsonToken;
-import com.google.api.client.json.gson.GsonFactory;
-import com.google.common.io.BaseEncoding;
-import com.google.common.io.BaseEncoding.DecodingException;
-
-import jakarta.annotation.PostConstruct;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpSession;
-
-/**
- * OpenID Connect authenticator for SSO integration.
- */
-public class OpenIdConnectAuthenticator implements SsoAuthenticator {
-
- /**
- * Default constructor.
- */
- public OpenIdConnectAuthenticator() {
- // Default constructor
- }
-
- private static final Logger logger = LogManager.getLogger(OpenIdConnectAuthenticator.class);
-
- private static final BaseEncoding BASE64_DECODER = BaseEncoding.base64().withSeparator("\n", 64);
-
- private static final BaseEncoding BASE64URL_DECODER = BaseEncoding.base64Url().withSeparator("\n", 64);
-
- /** Configuration key for OpenID Connect authorization server URL. */
- protected static final String OIC_AUTH_SERVER_URL = "oic.auth.server.url";
-
- /** Configuration key for OpenID Connect client ID. */
- protected static final String OIC_CLIENT_ID = "oic.client.id";
-
- /** Configuration key for OpenID Connect scope. */
- protected static final String OIC_SCOPE = "oic.scope";
-
- /** Configuration key for OpenID Connect redirect URL. */
- protected static final String OIC_REDIRECT_URL = "oic.redirect.url";
-
- /** Configuration key for OpenID Connect token server URL. */
- protected static final String OIC_TOKEN_SERVER_URL = "oic.token.server.url";
-
- /** Configuration key for OpenID Connect client secret. */
- protected static final String OIC_CLIENT_SECRET = "oic.client.secret";
-
- /** Session key for OpenID Connect state parameter. */
- protected static final String OIC_STATE = "OIC_STATE";
-
- /** Configuration key for OpenID Connect base URL. */
- protected static final String OIC_BASE_URL = "oic.base.url";
-
- /** HTTP transport for OpenID Connect requests. */
- protected final HttpTransport httpTransport = new NetHttpTransport();
-
- /** JSON factory for OpenID Connect response parsing. */
- protected final JsonFactory jsonFactory = GsonFactory.getDefaultInstance();
-
- /**
- * Initializes the OpenID Connect authenticator.
- */
- @PostConstruct
- public void init() {
- if (logger.isDebugEnabled()) {
- logger.debug("Initializing {}", this.getClass().getSimpleName());
- }
- ComponentUtil.getSsoManager().register(this);
- }
-
- @Override
- public LoginCredential getLoginCredential() {
- return LaRequestUtil.getOptionalRequest().map(request -> {
- if (logger.isDebugEnabled()) {
- logger.debug("Logging in with OpenID Connect Authenticator");
- }
- final HttpSession session = request.getSession(false);
- if (session != null) {
- final String sesState = (String) session.getAttribute(OIC_STATE);
- if (StringUtil.isNotBlank(sesState)) {
- session.removeAttribute(OIC_STATE);
- final String code = request.getParameter("code");
- final String reqState = request.getParameter("state");
- if (logger.isDebugEnabled()) {
- logger.debug("code: {}, state(request): {}, state(session): {}", code, reqState, sesState);
- }
- if (sesState.equals(reqState)) {
- final String error = request.getParameter("error");
- if (StringUtil.isNotBlank(error)) {
- // The provider answered this login with an error response (RFC 6749 section
- // 4.1.2.1). Falling through to a new authorization request would either bounce
- // between the two servers until the browser gives up, when the provider keeps
- // refusing, or hand back a code and log the user in anyway, when it refuses only
- // because the user declined the consent. Report it instead, so the caller shows
- // the login error.
- logger.warn("The OpenID provider rejected the authorization request: error={}, error_description={}", error,
- request.getParameter("error_description"));
- return null;
- }
- if (StringUtil.isNotBlank(code)) {
- return processCallback(request, code);
- }
- }
- }
- }
-
- return new ActionResponseCredential(() -> HtmlResponse.fromRedirectPathAsIs(getAuthUrl(request)));
- }).orElse(null);
- }
-
- /**
- * Gets the authorization URL for OpenID Connect.
- *
- * @param request the HTTP servlet request
- * @return the authorization URL
- */
- protected String getAuthUrl(final HttpServletRequest request) {
- // UUID.randomUUID is backed by SecureRandom and varies in 122 bits. The state is the only
- // thing standing between a login and a forged callback (RFC 6749 section 10.12), and
- // org.codelibs.core.net.UuidUtil, which this used to call, is
- // hex(localIP) + hex(identityHashCode(RANDOM)) + hex((int) (currentTimeMillis() >> 32)) +
- // hex(SecureRandom.nextInt()): its first 16 hex characters are constant for the life of
- // the JVM and under 32 bits actually vary per call. The value is only ever compared with
- // equals() against the copy held in the session, so its length and format are free.
- final String state = UUID.randomUUID().toString();
- request.getSession().setAttribute(OIC_STATE, state);
- return new AuthorizationCodeRequestUrl(getOicAuthServerUrl(), getOicClientId())//
- .setScopes(Arrays.asList(getOicScope()))//
- .setResponseTypes(Arrays.asList("code"))//
- .setRedirectUri(getOicRedirectUrl())//
- .setState(state)//
- .build();
- }
-
- /**
- * Decodes a Base64 string to bytes.
- *
- * @param base64String the Base64 string to decode
- * @return the decoded bytes, or null if input is null
- */
- protected byte[] decodeBase64(final String base64String) {
- if (base64String == null) {
- return null;
- }
- try {
- return BASE64_DECODER.decode(base64String);
- } catch (final IllegalArgumentException e) {
- if (e.getCause() instanceof DecodingException) {
- return BASE64URL_DECODER.decode(base64String.trim());
- }
- throw e;
- }
- }
-
- /**
- * Processes the callback from OpenID Connect provider.
- *
- * @param request the HTTP servlet request
- * @param code the authorization code
- * @return the login credential
- */
- protected LoginCredential processCallback(final HttpServletRequest request, final String code) {
- try {
- final TokenResponse tr = getTokenUrl(code);
-
- // Everything below reads a document the provider controls. Each malformed shape has to end
- // as a returned null, which the caller turns into the SSO login error, and not as a thrown
- // RuntimeException, which leaves the browser on a system error page.
- if (!(tr.get("id_token") instanceof final String idToken) || StringUtil.isBlank(idToken)) {
- logger.warn("The token response carries no id_token, so there is no user to log in.");
- return null;
- }
- final String[] jwt = idToken.split("\\.");
- if (jwt.length != 3) {
- logger.warn("The id_token is not a JWT: it has {} dot-separated segments instead of 3.", jwt.length);
- return null;
- }
- final String jwtHeader = new String(decodeBase64(jwt[0]), Constants.UTF_8_CHARSET);
- final String jwtClaim = new String(decodeBase64(jwt[1]), Constants.UTF_8_CHARSET);
- final String jwtSignature = new String(decodeBase64(jwt[2]), Constants.UTF_8_CHARSET);
-
- if (logger.isDebugEnabled()) {
- logger.debug("jwtHeader={}", jwtHeader);
- logger.debug("jwtClaim={}", jwtClaim);
- // The signature is raw bytes, not text. Writing the decoded string put control and
- // invalid-UTF-8 bytes straight into fess.log, which makes the file itself count as
- // binary: grep and the rest of the usual log tooling then skip it silently.
- logger.debug("jwtSignature: {} encoded characters, not validated", jwt[2].length());
- }
-
- // SECURITY WARNING: JWT signature validation is not implemented.
- // This is a critical security vulnerability. The ID token should be validated
- // to ensure it was issued by the expected OpenID Connect provider and has not been tampered with.
- // TODO: Implement JWT signature validation using the provider's public key
-
- final Map attributes = new HashMap<>();
- attributes.put("accesstoken", tr.getAccessToken());
- attributes.put("refreshtoken", tr.getRefreshToken() == null ? "null" : tr.getRefreshToken());
- attributes.put("tokentype", tr.getTokenType());
- attributes.put("expire", tr.getExpiresInSeconds());
- attributes.put("jwtheader", jwtHeader);
- attributes.put("jwtclaim", jwtClaim);
- attributes.put("jwtsignature", jwtSignature);
-
- if (logger.isDebugEnabled()) {
- // Not the whole attribute map: it holds the access token and the refresh token, which
- // are bearer credentials for the provider. The documentation tells an administrator to
- // turn this logger up to debug when a login misbehaves, so whatever it prints ends up
- // in a file that is read, copied into issue reports and shipped to log collectors.
- logger.debug("tokenType={}, expiresInSeconds={}, refreshToken={}", tr.getTokenType(), tr.getExpiresInSeconds(),
- tr.getRefreshToken() == null ? "absent" : "present");
- }
- parseJwtClaim(jwtClaim, attributes);
-
- final OpenIdConnectCredential credential = new OpenIdConnectCredential(attributes);
- if (StringUtil.isBlank(credential.getUserId())) {
- // The user id is the email claim. Without it the credential resolves to a user with a
- // null name, which the login itself accepts and every later request then fails on, so
- // the session has to be refused here rather than created and left unusable.
- logger.warn("The ID token has no email claim, which is the user id. Check that {} requests it.", OIC_SCOPE);
- return null;
- }
- return credential;
- } catch (final IOException | IllegalArgumentException e) {
- // This endpoint is anonymous, so anyone can drive a failing callback. A message keeps a
- // misbehaving provider diagnosable; the stack trace stays behind the debug level so an
- // unauthenticated client cannot fill the log with them.
- logger.warn("Failed to process the OpenID Connect callback: {}", e.getMessage());
- if (logger.isDebugEnabled()) {
- logger.debug("Failed to process callback request.", e);
- }
- }
- return null;
- }
-
- /**
- * Parses the JWT claim and extracts attributes.
- *
- * @param jwtClaim the JWT claim string
- * @param attributes the attributes map to populate
- * @throws IOException if an I/O error occurs
- */
- protected void parseJwtClaim(final String jwtClaim, final Map attributes) throws IOException {
- try (final JsonParser jsonParser = jsonFactory.createJsonParser(jwtClaim)) {
- attributes.putAll(parseObject(jsonParser));
- }
- }
-
- /**
- * Parses primitive values from JSON parser.
- *
- * @param jsonParser the JSON parser
- * @return the parsed primitive value
- * @throws IOException if an I/O error occurs
- */
- protected Object parsePrimitive(final JsonParser jsonParser) throws IOException {
- final JsonToken token = jsonParser.getCurrentToken();
- return switch (token) {
- case VALUE_STRING -> jsonParser.getText();
- case VALUE_NUMBER_INT -> jsonParser.getLongValue();
- case VALUE_NUMBER_FLOAT -> jsonParser.getDoubleValue();
- case VALUE_TRUE -> true;
- case VALUE_FALSE -> false;
- case VALUE_NULL -> null;
- default -> null; // Or throw an exception if unexpected token
- };
- }
-
- /**
- * Parses array values from JSON parser.
- *
- * @param jsonParser the JSON parser
- * @return the parsed array as a list
- * @throws IOException if an I/O error occurs
- */
- protected Object parseArray(final JsonParser jsonParser) throws IOException {
- final List list = new ArrayList<>();
- while (jsonParser.nextToken() != JsonToken.END_ARRAY) {
- if (jsonParser.getCurrentToken() == JsonToken.START_OBJECT) {
- list.add(parseObject(jsonParser));
- } else if (jsonParser.getCurrentToken() == JsonToken.START_ARRAY) {
- list.add(parseArray(jsonParser)); // Nested array
- } else {
- list.add(parsePrimitive(jsonParser));
- }
- }
-
- return list;
- }
-
- /**
- * Parses object values from JSON parser.
- *
- * @param jsonParser the JSON parser
- * @return the parsed object as a map
- * @throws IOException if an I/O error occurs
- */
- protected Map parseObject(final JsonParser jsonParser) throws IOException {
- final Map nestedMap = new HashMap<>();
- while (jsonParser.nextToken() != JsonToken.END_OBJECT) {
- final String fieldName = jsonParser.getCurrentName();
- if (fieldName != null) {
- jsonParser.nextToken(); // Move to the value of the current field
-
- if (jsonParser.getCurrentToken() == JsonToken.START_ARRAY) {
- nestedMap.put(fieldName, parseArray(jsonParser));
- } else if (jsonParser.getCurrentToken() == JsonToken.START_OBJECT) {
- nestedMap.put(fieldName, parseObject(jsonParser));
- } else {
- nestedMap.put(fieldName, parsePrimitive(jsonParser));
- }
- }
- }
- return nestedMap;
- }
-
- /**
- * Gets the token response from the OpenID Connect provider.
- *
- * @param code the authorization code
- * @return the token response
- * @throws IOException if an I/O error occurs
- */
- protected TokenResponse getTokenUrl(final String code) throws IOException {
- return new AuthorizationCodeTokenRequest(httpTransport, jsonFactory, new GenericUrl(getOicTokenServerUrl()), code)//
- .setGrantType("authorization_code")//
- .setRedirectUri(getOicRedirectUrl())//
- .set("client_id", getOicClientId())//
- .set("client_secret", getOicClientSecret())//
- .execute();
- }
-
- /**
- * Gets the OpenID Connect client secret.
- *
- * @return the client secret
- */
- protected String getOicClientSecret() {
- return ComponentUtil.getSystemProperties().getProperty(OIC_CLIENT_SECRET, StringUtil.EMPTY);
- }
-
- /**
- * Gets the OpenID Connect token server URL.
- *
- * @return the token server URL
- */
- protected String getOicTokenServerUrl() {
- return ComponentUtil.getSystemProperties().getProperty(OIC_TOKEN_SERVER_URL, "https://accounts.google.com/o/oauth2/token");
- }
-
- /**
- * Gets the OpenID Connect redirect URL.
- *
- * @return the redirect URL
- */
- protected String getOicRedirectUrl() {
- final String redirectUrl = ComponentUtil.getSystemProperties().getProperty(OIC_REDIRECT_URL);
- return redirectUrl != null ? redirectUrl : buildDefaultRedirectUrl();
- }
-
- /**
- * Builds a default redirect URL for OpenID Connect based on the environment.
- * Uses the configured base URL or defaults to http://localhost:8080 for compatibility
- * with common OIDC provider configurations.
- *
- * @return the default redirect URL
- */
- protected String buildDefaultRedirectUrl() {
- final DynamicProperties systemProperties = ComponentUtil.getSystemProperties();
- String baseUrl = systemProperties.getProperty(OIC_BASE_URL);
- if (StringUtil.isBlank(baseUrl)) {
- baseUrl = "http://localhost:8080";
- }
- if (baseUrl.endsWith("/")) {
- baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
- }
- return baseUrl + "/sso/";
- }
-
- /**
- * Gets the OpenID Connect scope.
- *
- * @return the scope
- */
- protected String getOicScope() {
- return ComponentUtil.getSystemProperties().getProperty(OIC_SCOPE, StringUtil.EMPTY);
- }
-
- /**
- * Gets the OpenID Connect client ID.
- *
- * @return the client ID
- */
- protected String getOicClientId() {
- return ComponentUtil.getSystemProperties().getProperty(OIC_CLIENT_ID, StringUtil.EMPTY);
- }
-
- /**
- * Gets the OpenID Connect authorization server URL.
- *
- * @return the authorization server URL
- */
- protected String getOicAuthServerUrl() {
- return ComponentUtil.getSystemProperties().getProperty(OIC_AUTH_SERVER_URL, "https://accounts.google.com/o/oauth2/auth");
- }
-
- @Override
- public void resolveCredential(final LoginCredentialResolver resolver) {
- resolver.resolve(OpenIdConnectCredential.class, credential -> OptionalEntity.of(credential.getUser()));
- }
-
-}
diff --git a/src/main/java/org/codelibs/fess/sso/saml/SamlAuthenticator.java b/src/main/java/org/codelibs/fess/sso/saml/SamlAuthenticator.java
deleted file mode 100644
index 9784abf11..000000000
--- a/src/main/java/org/codelibs/fess/sso/saml/SamlAuthenticator.java
+++ /dev/null
@@ -1,1605 +0,0 @@
-/*
- * Copyright 2012-2025 CodeLibs Project and the Others.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
- * either express or implied. See the License for the specific language
- * governing permissions and limitations under the License.
- */
-package org.codelibs.fess.sso.saml;
-
-import java.io.OutputStreamWriter;
-import java.io.Writer;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.IdentityHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.regex.Pattern;
-import java.util.stream.Collectors;
-
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
-import org.codelibs.core.lang.StringUtil;
-import org.codelibs.core.misc.DynamicProperties;
-import org.codelibs.fess.app.web.base.login.ActionResponseCredential;
-import org.codelibs.fess.app.web.base.login.FessLoginAssist;
-import org.codelibs.fess.app.web.base.login.FessLoginAssist.LoginCredentialResolver;
-import org.codelibs.fess.app.web.base.login.SamlCredential;
-import org.codelibs.fess.app.web.base.login.SamlCredential.SamlUser;
-import org.codelibs.fess.crawler.Constants;
-import org.codelibs.fess.exception.SsoLoginException;
-import org.codelibs.fess.exception.SsoMessageException;
-import org.codelibs.fess.exception.SsoProcessException;
-import org.codelibs.fess.exception.SsoStateException;
-import org.codelibs.fess.mylasta.action.FessUserBean;
-import org.codelibs.fess.sso.SsoAuthenticator;
-import org.codelibs.fess.sso.SsoResponseType;
-import org.codelibs.fess.util.ComponentUtil;
-import org.codelibs.saml2.Auth;
-import org.codelibs.saml2.core.authn.AuthnRequestParams;
-import org.codelibs.saml2.core.exception.SAMLException;
-import org.codelibs.saml2.core.exception.ValidationException;
-import org.codelibs.saml2.core.logout.LogoutRequest;
-import org.codelibs.saml2.core.logout.LogoutRequestParams;
-import org.codelibs.saml2.core.replay.InMemoryReplayCache;
-import org.codelibs.saml2.core.replay.ReplayCache;
-import org.codelibs.saml2.core.settings.Saml2Settings;
-import org.codelibs.saml2.core.settings.SettingsBuilder;
-import org.codelibs.saml2.core.util.Util;
-import org.dbflute.optional.OptionalEntity;
-import org.dbflute.optional.OptionalThing;
-import org.lastaflute.core.message.UserMessages;
-import org.lastaflute.web.login.credential.LoginCredential;
-import org.lastaflute.web.response.ActionResponse;
-import org.lastaflute.web.response.HtmlResponse;
-import org.lastaflute.web.response.StreamResponse;
-import org.lastaflute.web.util.LaRequestUtil;
-import org.lastaflute.web.util.LaResponseUtil;
-import org.w3c.dom.Document;
-
-import jakarta.annotation.PostConstruct;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-import jakarta.servlet.http.HttpSession;
-
-/**
- * Authenticator for SAML 2.0.
- *
- * This authenticator enables Single Sign-On (SSO) using SAML 2.0 protocol
- * with Identity Providers such as Okta, Azure AD, OneLogin, etc.
- *
- * Required Configuration
- * Add the following properties to {@code system.properties}:
- *
- * # Enable SAML SSO
- * sso.type=saml
- *
- * # Identity Provider settings (obtain from your IdP)
- * saml.idp.entityid=http://www.okta.com/xxxxx
- * saml.idp.single_sign_on_service.url=https://your-domain.okta.com/app/xxxxx/sso/saml
- * saml.idp.x509cert=MIIDqjCCApKgAwIBAgIGAYMwfYAwMA0G...
- *
- *
- * Service Provider URL Configuration
- * By default, the SP URLs use {@code http://localhost:8080} as the base URL.
- * For production or when the IdP is configured with a different URL, you should
- * set one of the following:
- *
- * Option 1: Set base URL (recommended for simplicity)
- *
- * # All SP URLs will be derived from this base URL
- * saml.sp.base.url=https://your-fess-server.example.com
- *
- *
- * Option 2: Set individual SP URLs
- *
- * # SP Entity ID (Audience URI in IdP)
- * saml.sp.entityid=https://your-fess-server.example.com/sso/metadata
- *
- * # Assertion Consumer Service URL
- * saml.sp.assertion_consumer_service.url=https://your-fess-server.example.com/sso/
- *
- * # Single Logout Service URL
- * saml.sp.single_logout_service.url=https://your-fess-server.example.com/sso/logout
- *
- *
- * Complete Configuration Example (Okta)
- *
- * sso.type=saml
- *
- * # IdP settings from Okta SAML setup instructions
- * saml.idp.entityid=http://www.okta.com/your-app-id
- * saml.idp.single_sign_on_service.url=https://your-domain.okta.com/app/your-app/your-app-id/sso/saml
- * saml.idp.x509cert=MIIDqjCCApKg... (your IdP certificate)
- *
- * # SP base URL (must match Audience URI configured in Okta)
- * saml.sp.base.url=https://your-fess-server.example.com
- *
- *
- * Optional Configuration
- *
- * # User attribute mapping
- * saml.attribute.group.name=groups
- * saml.attribute.role.name=roles
- *
- * # Default groups/roles for authenticated users
- * saml.default.groups=user
- * saml.default.roles=user
- *
- * An assertion that carries the same attribute name on more than one element is refused, and
- * the login fails before any of the mapping above is reached. An IdP that emits one
- * {@code } element per value produces such an assertion -- Keycloak does unless the
- * {@code single} option of its role and group mappers is enabled -- and the repeats are accepted
- * and merged only with:
- *
- * saml.security.allow_duplicated_attribute_name=true
- *
- *
- * Security Settings (Production)
- * For production environments, consider enabling these security features:
- *
- * saml.security.authnrequest_signed=true
- * saml.security.want_messages_signed=true
- * saml.security.want_assertions_signed=true
- *
- * {@code saml.security.want_messages_signed} matters in particular once
- * {@code saml.idp.single_logout_service.url} is configured. It defaults to {@code false} because
- * not every IdP signs its LogoutRequest, but while it is {@code false} the single logout service
- * accepts a LogoutRequest that nobody authenticated, and reaching it needs no knowledge of the
- * deployment: {@code Issuer} is optional in the SAML protocol schema and java-saml compares it
- * only when the element is present, so a LogoutRequest that omits it is never matched against the
- * IdP entity ID. A session logged in through SAML is protected by
- * {@link #isLogoutRequestForAnotherUser}, which refuses to end a session the LogoutRequest does
- * not name; what remains exposed is a session whose NameID the sender already knows, and any
- * session that did not come from SAML at all, because there is then no NameID to compare. The
- * impact is a forced logout, not account takeover. This is reported once as
- * {@code unsigned_logoutrequest_accepted} in the insecure-settings warning.
- *
- * Session Cookie Settings (Required)
- * The IdP returns the assertion as a cross-site POST to the assertion consumer service.
- * A {@code SameSite=Lax} cookie is not sent on such a request, so the shipped default in
- * {@code tomcat_config.properties} has to be changed for SAML:
- *
- * tomcat.sameSiteCookies = none
- *
- * {@code none} is only accepted by browsers on a {@code Secure} cookie, so Fess must be
- * served over HTTPS.
- *
- * @see Fess Documentation
- */
-public class SamlAuthenticator implements SsoAuthenticator {
-
- /**
- * Constructor.
- */
- public SamlAuthenticator() {
- }
-
- private static final Logger logger = LogManager.getLogger(SamlAuthenticator.class);
-
- /**
- * The prefix for SAML properties.
- */
- protected static final String SAML_PREFIX = "saml.";
-
- /**
- * The session key holding the IDs of the AuthnRequests sent to the IdP that have not been
- * answered yet. Each ID is compared with the InResponseTo of the SAML response.
- *
- * The value is a {@code Map} of AuthnRequest ID to the time it was created,
- * not a single ID: a browser with several tabs open sends one AuthnRequest per tab, and a
- * single slot means the second overwrites the first, after which the first assertion to come
- * back consumes the slot, fails the InResponseTo comparison and takes both logins down with
- * it. {@code FessSearchAction} redirects every unauthenticated page hit to {@code /sso/}, so
- * that happens as soon as a session expires with more than one tab open.
- *
- * The key is unchanged from the release that stored a bare {@link String} here, so that an
- * existing session keeps working across an upgrade; see {@link #getRequestIdMap(HttpSession)}
- * for how such a value is migrated.
- */
- protected static final String SAML_STATE = "SAML_STATE";
-
- /**
- * The property key for the SAML SP base URL.
- */
- protected static final String SAML_SP_BASE_URL = "saml.sp.base.url";
-
- /** Upper bound on the length of a sender-supplied NameID embedded in a log message. */
- protected static final int MAX_LOGGED_NAME_ID_LENGTH = 64;
-
- /**
- * Upper bound on the length of a rejection reason embedded in a log message. Longer than a
- * NameID because the reason is a sentence that quotes what it objected to -- an entity ID, a
- * destination, an audience -- and truncating it to a NameID's length would cut off the part
- * that identifies the problem.
- */
- protected static final int MAX_LOGGED_FAILURE_REASON_LENGTH = 512;
-
- /**
- * Characters that must not be copied verbatim into a log message. {@code \p{Cntrl}} alone is
- * ASCII-only, so the Unicode break characters a log viewer still renders as a new line are
- * listed explicitly.
- */
- private static final Pattern LOG_UNSAFE_PATTERN = Pattern.compile("[\\p{Cntrl}\\u0085\\u2028\\u2029]");
-
- /**
- * The property key for how long, in seconds, an unanswered AuthnRequest ID stays usable. Only
- * a positive value is honoured; see {@link #getRequestIdTtl()}.
- */
- protected static final String SAML_REQUEST_ID_TTL = "saml.request.id.ttl";
-
- /**
- * The time-to-live applied to an unanswered AuthnRequest ID when
- * {@link #SAML_REQUEST_ID_TTL} is absent, blank, not a number, or not positive. One hour, the
- * same default {@code EntraIdAuthenticator} uses for an OpenID Connect state, and comfortably
- * longer than any interactive login at an IdP.
- */
- protected static final String DEFAULT_REQUEST_ID_TTL = "3600";
-
- /**
- * The number of unanswered AuthnRequest IDs kept per session when {@code fess_sso++.xml}
- * leaves the cap alone, and the value {@link #setMaxRequestIds} falls back to when the
- * configured one is not positive. Ten abandoned logins in one session is already well past
- * anything a person does by hand.
- */
- protected static final int DEFAULT_MAX_REQUEST_IDS = 10;
-
- /**
- * Maximum number of unanswered AuthnRequest IDs kept per session. Every visit to
- * {@code /sso/} without a SAML response stores one, and {@code /sso/} is anonymous and
- * answers GET, so without a cap a page that embeds it as a sub-resource would grow the
- * session attribute without bound. It also bounds the number of candidates
- * {@link #processSamlResponse} tries.
- *
- * Always positive when it is set through {@link #setMaxRequestIds}, which is the only path
- * a configured value takes; a subclass that assigns the field directly is on its own.
- */
- protected int maxRequestIds = DEFAULT_MAX_REQUEST_IDS;
-
- private Map defaultSettings;
-
- /**
- * Cache of processed assertion IDs, used to reject replayed assertions.
- *
- * Note: the cache is held in memory by this instance and is not shared in a
- * multi-instance deployment. That is also why SAML SSO needs sticky sessions: the
- * assertion has to reach the instance whose session holds the matching AuthnRequest ID.
- * It is that ID check, not this cache, which rejects an assertion replayed to another
- * instance; the cache catches a repeated POST within a single session.
- */
- private final ReplayCache replayCache = new InMemoryReplayCache();
-
- /**
- * The security warnings reported the last time they were logged, so that a settings
- * rebuild does not repeat them until the settings change.
- */
- private final AtomicReference> loggedSecurityWarnings = new AtomicReference<>();
-
- /**
- * The most recently built settings, paired with the parameters they were built from.
- *
- * @param params the parameters the settings were built from; values are always {@link String},
- * never a {@link java.net.URL}, whose {@code equals} would resolve DNS
- * @param settings the settings built from {@code params}
- */
- private record CachedSettings(Map params, Saml2Settings settings) {
- }
-
- /**
- * The settings built for the parameters currently in effect.
- *
- * Building is not cheap and is not silent: it re-parses the IdP certificate and the SP
- * private key, and {@code SettingsBuilder.build()} logs one line per security warning, so a
- * per-request build defeats the deduplication in {@link #logSecurityWarnings(Saml2Settings)}.
- *
- * An {@link AtomicReference} rather than a plain field because it is required for safe
- * publication: every field of {@link Saml2Settings} is non-final and non-volatile, so another
- * thread could otherwise observe a partially initialized instance.
- */
- private final AtomicReference cachedSettings = new AtomicReference<>();
-
- /**
- * Initializes the SamlAuthenticator.
- */
- @PostConstruct
- public void init() {
- if (logger.isDebugEnabled()) {
- logger.debug("Initializing {}", this.getClass().getSimpleName());
- }
- ComponentUtil.getSsoManager().register(this);
- defaultSettings = createDefaultSettings();
- }
-
- /**
- * Creates the settings that are applied before the {@code saml.} system properties.
- *
- * NOTE: Many security settings are set to false for compatibility.
- * For production use, it is STRONGLY RECOMMENDED to enable security features:
- * {@code saml.security.authnrequest_signed}, {@code saml.security.want_messages_signed}
- * and {@code saml.security.want_assertions_signed}.
- *
- * The SP endpoint URLs are not included here because they are derived from
- * {@code saml.sp.base.url}, which can be changed at runtime. They are built by
- * {@link #buildSettingsParams()}.
- *
- * @return The default settings.
- */
- protected Map createDefaultSettings() {
- final Map settings = new HashMap<>();
- settings.put("onelogin.saml2.strict", "true");
- settings.put("onelogin.saml2.debug", "false");
- settings.put("onelogin.saml2.sp.assertion_consumer_service.binding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST");
- settings.put("onelogin.saml2.sp.single_logout_service.binding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect");
- settings.put("onelogin.saml2.sp.nameidformat", "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress");
- settings.put("onelogin.saml2.idp.single_sign_on_service.binding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect");
- settings.put("onelogin.saml2.idp.single_logout_service.binding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect");
- settings.put("onelogin.saml2.security.nameid_encrypted", "false");
- settings.put("onelogin.saml2.security.authnrequest_signed", "false");
- settings.put("onelogin.saml2.security.logoutrequest_signed", "false");
- settings.put("onelogin.saml2.security.logoutresponse_signed", "false");
- settings.put("onelogin.saml2.security.want_messages_signed", "false");
- settings.put("onelogin.saml2.security.want_assertions_signed", "false");
- settings.put("onelogin.saml2.security.want_assertions_encrypted", "false");
- settings.put("onelogin.saml2.security.want_nameid_encrypted", "false");
- settings.put("onelogin.saml2.security.requested_authncontext", "urn:oasis:names:tc:SAML:2.0:ac:classes:Password");
- settings.put("onelogin.saml2.security.requested_authncontextcomparison", "exact");
- settings.put("onelogin.saml2.security.want_xml_validation", "true");
- settings.put("onelogin.saml2.security.signature_algorithm", "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256");
- settings.put("onelogin.saml2.organization.name", "CodeLibs");
- settings.put("onelogin.saml2.organization.displayname", "Fess");
- settings.put("onelogin.saml2.organization.url", "https://fess.codelibs.org/");
- settings.put("onelogin.saml2.contacts.technical.given_name", "Technical Guy");
- settings.put("onelogin.saml2.contacts.technical.email_address", "technical@example.com");
- settings.put("onelogin.saml2.contacts.support.given_name", "Support Guy");
- settings.put("onelogin.saml2.contacts.support.email_address", "support@example.com");
- return settings;
- }
-
- /**
- * Builds a default URL for SAML endpoints.
- * Uses the configured base URL or defaults to http://localhost:8080 for compatibility
- * with common SAML IdP configurations.
- *
- * @param path the path to append to the base URL
- * @return the complete URL
- */
- protected String buildDefaultUrl(final String path) {
- final DynamicProperties systemProperties = ComponentUtil.getSystemProperties();
- String baseUrl = systemProperties.getProperty(SAML_SP_BASE_URL);
- if (StringUtil.isBlank(baseUrl)) {
- baseUrl = "http://localhost:8080";
- }
- if (baseUrl.endsWith("/")) {
- baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
- }
- return baseUrl + path;
- }
-
- /**
- * Builds the parameters the SAML settings are built from.
- *
- * The {@code saml.} system properties are applied without filtering out blank values:
- * {@code SettingsBuilder} already treats a blank value as absent, so a present-but-blank
- * property falls through to the library default. That is what lets
- * {@code saml.security.requested_authncontext=} suppress the {@code RequestedAuthnContext}
- * element, which is the documented way of not constraining the authentication method.
- *
- * The three SP endpoint URLs are the exception: they are computed from
- * {@code saml.sp.base.url} rather than defaulted by the library, so they are filled in only
- * when the matching property is absent or blank.
- *
- * Every value in the returned map is a {@link String}. A {@link java.net.URL} must never be
- * put in it, because the map is compared with {@code equals} and {@code URL.equals} resolves
- * DNS.
- *
- * @return The parameters for {@link SettingsBuilder}.
- */
- protected Map buildSettingsParams() {
- final Map params = new HashMap<>(defaultSettings);
- final DynamicProperties systemProperties = ComponentUtil.getSystemProperties();
- systemProperties.entrySet().stream().forEach(e -> {
- final String key = e.getKey().toString();
- if (!key.startsWith(SAML_PREFIX)) {
- return;
- }
- params.put("onelogin.saml2." + key.substring(SAML_PREFIX.length()), e.getValue());
- });
- // resolved here, not in createDefaultSettings(), because saml.sp.base.url can be changed
- // at runtime from the admin UI
- putComputedSpUrl(params, "onelogin.saml2.sp.entityid", "/sso/metadata");
- putComputedSpUrl(params, "onelogin.saml2.sp.assertion_consumer_service.url", "/sso/");
- putComputedSpUrl(params, "onelogin.saml2.sp.single_logout_service.url", "/sso/logout");
- return params;
- }
-
- /**
- * Derives an SP endpoint URL from {@code saml.sp.base.url} unless the corresponding property
- * already carries a usable value.
- *
- * A present-but-blank property must not wipe out the derived URL: the library would treat
- * it as absent and leave the SP endpoint unset, which fails {@code checkSPSettings()}.
- *
- * @param params The parameters being built.
- * @param key The {@code onelogin.saml2.} key to fill in.
- * @param path The path to append to the base URL.
- */
- protected void putComputedSpUrl(final Map params, final String key, final String path) {
- if (params.get(key) instanceof final String s && StringUtil.isNotBlank(s)) {
- return;
- }
- params.put(key, buildDefaultUrl(path));
- }
-
- /**
- * Gets the SAML settings.
- *
- * The settings are cached and rebuilt only when the parameters change, because building
- * them re-parses the IdP certificate and the SP private key and logs the security warnings
- * again.
- *
- * @return The SAML settings.
- */
- protected Saml2Settings getSettings() {
- final Map params = buildSettingsParams();
- final CachedSettings cached = cachedSettings.get();
- if (cached != null && cached.params().equals(params)) {
- return cached.settings();
- }
- // deliberately get()/set() rather than updateAndGet() or a synchronized block: the
- // mapping function of updateAndGet() may be retried, and building has logging side
- // effects that must not be repeated. Two threads building at once is harmless; both
- // produce equivalent settings and the last write wins.
- final Saml2Settings settings = new SettingsBuilder().fromValues(params).build();
- settings.setReplayCache(replayCache);
- logSecurityWarnings(settings);
- cachedSettings.set(new CachedSettings(params, settings));
- return settings;
- }
-
- /**
- * Logs the security warnings reported for the given settings.
- * The warnings are logged again only when they change, so that a settings rebuild does not
- * repeat them.
- *
- * @param settings The SAML settings.
- */
- protected void logSecurityWarnings(final Saml2Settings settings) {
- final List warnings = new ArrayList<>(settings.getSecurityWarnings());
- if (settings.getIdpSingleLogoutServiceResponseUrl() != null && !settings.getWantMessagesSigned()) {
- // /sso/logout accepts a LogoutRequest that is not signed, so anyone who can lure a
- // logged-in user to a crafted URL can reach it; not even the IdP entity ID is needed,
- // since Issuer is optional in the protocol schema and java-saml compares it only when
- // it is present. isLogoutRequestForAnotherUser() keeps such a request from ending a
- // SAML session it does not name, but a session whose NameID the sender already knows,
- // and a session that did not come from SAML and therefore has no NameID to compare,
- // are still ended by it.
- warnings.add("unsigned_logoutrequest_accepted");
- }
- final Set allowedKeyTransport = settings.getAllowedKeyTransportAlgorithms();
- if (settings.getSPkey() != null && (allowedKeyTransport == null || allowedKeyTransport.isEmpty())) {
- // With an SP private key configured, /sso/ decrypts an EncryptedAssertion before it
- // validates anything: SamlResponse decrypts in its constructor, and processResponse()
- // only calls isValid() afterwards. The endpoint is anonymous, and an AuthnRequest ID
- // to quote back is one GET /sso/ away, so an unauthenticated caller can drive the SP
- // private key through RSA decryption with a ciphertext of their choosing.
- // java-saml accepts every key transport algorithm when the allow-list is unset, which
- // includes the RSA-1_5 padding that Bleichenbacher-style attacks target. Fess does not
- // set it, so say so rather than changing a default that would break an IdP relying on
- // the old algorithm.
- warnings.add("key_transport_algorithms_not_restricted");
- }
- if (!warnings.equals(loggedSecurityWarnings.getAndSet(warnings)) && !warnings.isEmpty()) {
- logger.warn("Insecure SAML settings: {}. See the SAML SSO documentation for the recommended values.",
- String.join(", ", warnings));
- }
- }
-
- @Override
- public LoginCredential getLoginCredential() {
- return LaRequestUtil.getOptionalRequest().map(request -> {
- if (logger.isDebugEnabled()) {
- logger.debug("Logging in with SAML Authenticator");
- }
-
- final HttpServletResponse response = LaResponseUtil.getResponse();
-
- if (containsSamlResponse(request)) {
- final HttpSession session = request.getSession(false);
- // counted here rather than inside removeExpiredRequestIds so that the pruning
- // itself keeps the signature it ships with; see logUnmatchedSamlResponseAfterExpiry
- int expiredCount = 0;
- if (session != null) {
- final Map requestIdMap = getRequestIdMap(session);
- final int pendingCount = requestIdMap.size();
- removeExpiredRequestIds(requestIdMap);
- if (!requestIdMap.isEmpty()) {
- try {
- return processSamlResponse(request, response, requestIdMap);
- } catch (final SAMLException e) {
- // The assertion consumer service is anonymous, and a SAMLResponse that
- // cannot be decoded or parsed is refused before the pending
- // AuthnRequest ID is consumed (see processSamlResponse), so the same
- // request can be repeated for the whole TTL. A stack trace per attempt
- // would let an unauthenticated client fill the log; SsoAction already
- // makes this split for SsoStateException.
- if (isDuplicatedAttributeName(e)) {
- logDuplicatedAttributeName();
- } else {
- logger.warn("Authentication failed: {}", describeSamlFailure(e));
- }
- if (logger.isDebugEnabled()) {
- logger.debug("Authentication failed.", e);
- }
- return null;
- } catch (final Exception e) {
- logger.warn("Authentication failed.", e);
- return null;
- }
- }
- // pruning is the only thing this thread removed, so an emptied map that was not
- // empty a moment ago says how many logins ran out of time. A concurrent request
- // of the same session can consume or evict an entry too, which would make this
- // an over-count; it only shapes a log line, and the response was unanswerable
- // by this thread either way.
- expiredCount = pendingCount;
- }
- // No session at all, or one that holds nothing still answerable: the assertion
- // cannot be tied to an AuthnRequest this server sent, so it is refused rather
- // than answered with another one.
- if (expiredCount > 0) {
- logUnmatchedSamlResponseAfterExpiry(expiredCount);
- } else if (hasExpiredSession(request)) {
- logUnmatchedSamlResponseAfterSessionExpiry();
- } else {
- logUnmatchedSamlResponse(0);
- }
- return null;
- }
-
- try {
- final Auth auth = new Auth(getSettings(), request, response);
- final AuthnRequestParams authnRequestParams = new AuthnRequestParams(false, false, true);
- final String loginUrl = auth.login(null, authnRequestParams, true);
- storeRequestIdInSession(request.getSession(), auth.getLastRequestId());
- return new ActionResponseCredential(() -> HtmlResponse.fromRedirectPathAsIs(loginUrl));
- } catch (final Exception e) {
- throw new SsoLoginException("Invalid SAML redirect URL.", e);
- }
-
- }).orElse(null);
- }
-
- /**
- * Renders a SAML failure as the single line that stands in for its stack trace at WARN.
- *
- * The cause chain is rendered, not just {@code getMessage()}, because the exception a
- * malformed response produces is often the one that says the least: java-saml wraps a parse
- * failure as {@code XMLParsingException("Failed to load XML data.", cause)}, whose own
- * message names neither what failed nor where. Everything actionable lives further down the
- * chain, so dropping it would trade a noisy log for a useless one.
- *
- * Split out as its own method so that an extension can reshape or shorten this line
- * without having to re-implement the WARN/DEBUG split around it. The full stack trace stays
- * available at DEBUG either way.
- *
- * @param throwable The failure to describe.
- * @return The chain rendered as {@code Type: message} entries joined by {@code " <- "},
- * stopping at the first cause already seen so that a cyclic chain terminates.
- */
- protected String describeSamlFailure(final Throwable throwable) {
- final Set seen = Collections.newSetFromMap(new IdentityHashMap<>());
- final StringBuilder buf = new StringBuilder();
- for (Throwable current = throwable; current != null && seen.add(current); current = current.getCause()) {
- if (buf.length() > 0) {
- buf.append(" <- ");
- }
- buf.append(current.getClass().getSimpleName());
- if (StringUtil.isNotBlank(current.getMessage())) {
- buf.append(": ").append(current.getMessage());
- }
- }
- return buf.toString();
- }
-
- /**
- * Answers a SAML response with the pending AuthnRequest it names.
- *
- * {@code Auth.processResponse} takes exactly one AuthnRequest ID and compares it with the
- * InResponseTo of the response, and java-saml 3.1.1 exposes no supported way of reading that
- * InResponseTo beforehand: {@code SamlResponse} has no getter for it, and the only other
- * route would be to base64-decode and parse the {@code SAMLResponse} parameter here, which
- * would add an XML parsing surface -- and therefore an XXE surface -- to an endpoint that is
- * anonymous by design. So the candidates are tried one at a time instead.
- *
- * That is cheap and, more importantly, safe, because of where the comparison sits in
- * {@code SamlResponse.isValid}: it runs before signature validation and before the assertion
- * ID is registered with the replay cache, so a candidate the response does not name fails
- * fast and leaves no trace behind. A candidate that fails for any other reason has already
- * passed the comparison, so there is nothing left to try and the loop stops there; that is
- * also what keeps the reported error the real one rather than the InResponseTo mismatch of
- * whichever candidate happened to be tried last.
- *
- * Only a response that authenticates consumes its AuthnRequest ID. Consuming it on failure
- * as well would look tidier, but telling "named this ID and was then rejected" apart from
- * "was rejected before the ID was even looked at" means enumerating java-saml's internal
- * ordering of checks, and getting that wrong hands anyone who can reach the assertion
- * consumer service -- a cross-site POST, since SAML requires {@code SameSite=none} -- a way
- * to burn a pending login per request. The TTL and {@link #maxRequestIds} bound the map
- * instead.
- *
- * With {@code saml.strict=false} the library skips the InResponseTo comparison entirely,
- * so the first candidate either authenticates or fails for a real reason and no further
- * candidate is tried. That matches the library's contract: without strict mode there is no
- * InResponseTo binding to match against.
- *
- * @param request The HTTP request carrying the SAML response.
- * @param response The HTTP response.
- * @param requestIdMap The pending AuthnRequest IDs of the session, already pruned of expired
- * entries. The matching entry is removed from it on success.
- * @return The login credential, or null when the response is not accepted.
- */
- protected LoginCredential processSamlResponse(final HttpServletRequest request, final HttpServletResponse response,
- final Map requestIdMap) {
- Auth lastAuth = null;
- for (final String requestId : getCandidateRequestIds(requestIdMap)) {
- final Auth auth = createAuth(request, response);
- auth.processResponse(requestId);
- if (auth.isAuthenticated()) {
- requestIdMap.remove(requestId);
- return createLoginCredential(request, response, auth);
- }
- lastAuth = auth;
- if (!isInResponseToMismatch(auth)) {
- break;
- }
- }
- if (lastAuth == null || isInResponseToMismatch(lastAuth)) {
- logUnmatchedSamlResponse(requestIdMap.size());
- return null;
- }
- final String errors = String.join(", ", lastAuth.getErrors());
- // The reason is reported whatever saml.debug is set to. getErrors() answers a category --
- // "invalid_response" for a bad signature, an expired assertion, a foreign audience and a
- // replay alike -- so on its own it tells an administrator only that the login failed. The
- // detail used to reach the log anyway because java-saml logged it at warn as well; it now
- // leaves that to whoever calls it, and getLastErrorReason() carries it either way.
- final String reason = lastAuth.getLastErrorReason();
- if (StringUtil.isNotBlank(reason)) {
- // The reason quotes the message it objected to -- an issuer, a destination, an
- // audience -- and this endpoint is anonymous, so the quoted part is a sender's own
- // input and is bounded and stripped of control characters like any other.
- logger.warn("Authentication Failure: {} - Reason: {}", errors, sanitizeForLog(reason, MAX_LOGGED_FAILURE_REASON_LENGTH));
- } else {
- logger.warn("Authentication Failure: {}", errors);
- }
- return null;
- }
-
- /**
- * Creates the {@link Auth} that processes one candidate AuthnRequest ID.
- *
- * A fresh instance per candidate is required, not an optimisation left undone:
- * {@code Auth} accumulates its errors, its authenticated flag and its last validation
- * exception across calls, so reusing one would report the first candidate's InResponseTo
- * mismatch alongside whatever the matching candidate produced.
- *
- * @param request The HTTP request.
- * @param response The HTTP response.
- * @return A new SAML authentication object.
- */
- protected Auth createAuth(final HttpServletRequest request, final HttpServletResponse response) {
- return new Auth(getSettings(), request, response);
- }
-
- /**
- * Returns the pending AuthnRequest IDs to try, most recently created first.
- *
- * Most recent first because the overwhelmingly common case is a single login, whose ID is
- * the newest one; every earlier entry is a tab or a visit the user abandoned. The list is
- * capped at {@link #maxRequestIds} even though the map is already bounded on write, so that
- * lowering the cap at runtime takes effect on the very next response rather than only once
- * the surplus entries have been evicted.
- *
- * @param requestIdMap The pending AuthnRequest IDs of the session.
- * @return The AuthnRequest IDs to try, in the order they are tried.
- */
- protected List getCandidateRequestIds(final Map requestIdMap) {
- return requestIdMap.entrySet()
- .stream()
- .sorted(Comparator.comparingLong((final Map.Entry e) -> e.getValue()).reversed())
- .limit(maxRequestIds)
- .map(Map.Entry::getKey)
- .collect(Collectors.toList());
- }
-
- /**
- * Returns whether the given attempt failed only because the response names a different
- * AuthnRequest, which is the one failure that says nothing about the response itself and is
- * therefore worth retrying with the next candidate.
- *
- * @param auth The attempt that did not authenticate.
- * @return true if the response's InResponseTo did not match the candidate that was tried.
- */
- protected boolean isInResponseToMismatch(final Auth auth) {
- return auth.getLastValidationException() instanceof final ValidationException e
- && e.getErrorCode() == ValidationException.WRONG_INRESPONSETO;
- }
-
- /**
- * Logs the one line reported when a SAML response cannot be tied to an AuthnRequest this
- * server sent.
- *
- * It is a warning and not a redirect back to the IdP on purpose: answering an unmatched
- * assertion with a fresh AuthnRequest sends the browser to an IdP that is already
- * authenticated, which posts the same kind of unmatched assertion straight back, and the
- * loop only ends when the browser gives up.
- *
- * The SameSite guidance below is what this case usually is, but only because the two ways
- * a login can instead have run out of time are reported elsewhere: an ID pruned by
- * {@link #SAML_REQUEST_ID_TTL} by {@link #logUnmatchedSamlResponseAfterExpiry(int)}, and a
- * session the container has already discarded by
- * {@link #logUnmatchedSamlResponseAfterSessionExpiry()}. Without that split every expired
- * login would read as a cookie misconfiguration, since neither leaves anything pending and
- * both therefore reach this line with a pending count of zero as well.
- *
- * @param pendingCount How many pending AuthnRequest IDs the session held, so that a log can
- * tell a missing session cookie apart from a response that simply matched none of
- * several live logins.
- */
- protected void logUnmatchedSamlResponse(final int pendingCount) {
- logger.warn("""
- Received a SAML response with no matching AuthnRequest ID in the session ({} pending).\
- The assertion consumer service is a cross-site POST, which does not carry a SameSite=Lax cookie;\
- see tomcat.sameSiteCookies in tomcat_config.properties.\
- An IdP-initiated (unsolicited) response is rejected for the same reason.""", pendingCount);
- }
-
- /**
- * Logs the one line reported when a SAML response arrives after every AuthnRequest ID its
- * session held had passed {@link #SAML_REQUEST_ID_TTL}.
- *
- * Kept apart from {@link #logUnmatchedSamlResponse(int)} because that line names
- * {@code tomcat.sameSiteCookies} as the cause, and here the session cookie demonstrably did
- * arrive: the session was found and it did hold pending IDs until this request pruned them.
- * Sending an operator whose cookie settings are already correct off to change them is what
- * the split avoids; the only line that told the two apart was the {@code debug} one in
- * {@link #removeExpiredRequestIds(Map)}, which is below the level a shipped Fess logs at.
- *
- * This is an ordinary event rather than a misconfiguration -- a user who starts a login and
- * finishes it at the IdP later than the TTL allows reaches it, and simply starting the login
- * again succeeds -- so it is worth telling apart from the cases that need an administrator.
- *
- * An extension that overrides {@link #logUnmatchedSamlResponse(int)} to reshape or suppress
- * that warning wants to override this one as well: until this method existed, the expiry case
- * was reported by that one with a pending count of zero.
- *
- * @param expiredCount How many pending AuthnRequest IDs had expired, that is, how many logins
- * the session still had in flight before the TTL removed them.
- */
- protected void logUnmatchedSamlResponseAfterExpiry(final int expiredCount) {
- logger.warn("""
- Received a SAML response after all {} pending AuthnRequest ID(s) of the session had expired.\
- The session cookie did reach this server, so this is not the SameSite case:\
- the login took longer to finish at the IdP than {} allows, and starting it again resolves it.""", expiredCount,
- SAML_REQUEST_ID_TTL);
- }
-
- /**
- * Logs the one line reported when a SAML response arrives with a session id the container no
- * longer recognises, so the session that held the AuthnRequest ID is gone rather than merely
- * empty.
- *
- * This, not {@link #logUnmatchedSamlResponseAfterExpiry(int)}, is what a login left too
- * long at the IdP actually reaches on a stock Fess, because the session runs out first: the
- * AuthnRequest ID is kept for {@link #DEFAULT_REQUEST_ID_TTL} seconds, an hour, while
- * {@code WEB-INF/web.xml} sets no {@code session-timeout} and nothing calls
- * {@code setMaxInactiveInterval}, which leaves the servlet container's own default of thirty
- * minutes. The session is therefore discarded, IDs and all, some half an hour before any of
- * those IDs can expire, and the response comes back to a {@code getSession(false)} that
- * returns null. Without this line that lands on {@link #logUnmatchedSamlResponse(int)} and is
- * reported as a {@code SameSite} cookie problem -- the very misdiagnosis the expiry line was
- * added to prevent, in the one case that occurs in practice.
- *
- * The two are told apart by {@link #hasExpiredSession(HttpServletRequest)}: a browser that
- * is not sending the session cookie sends no session id at all, so a request that does carry
- * one demonstrably kept the cookie and lost only the session behind it.
- *
- * Like the TTL case this is an ordinary event rather than a misconfiguration, and starting
- * the login again resolves it. It names the container's session timeout instead of
- * {@link #SAML_REQUEST_ID_TTL} on purpose, since raising the TTL cannot extend a session that
- * is already the shorter of the two.
- *
- * An extension that overrides {@link #logUnmatchedSamlResponse(int)} to reshape or suppress
- * that warning wants to override this one as well: until this method existed, this case was
- * reported by that one with a pending count of zero.
- */
- protected void logUnmatchedSamlResponseAfterSessionExpiry() {
- logger.warn("""
- Received a SAML response after the session it belongs to had expired.\
- The browser did return its session cookie, so this is not the SameSite case:\
- the session, and with it the AuthnRequest ID it held, was discarded by the\
- container's session timeout rather than by {}, so raising that value does not help.\
- Starting the login again resolves it.""", SAML_REQUEST_ID_TTL);
- }
-
- /**
- * Returns whether the given failure is the library refusing an assertion that carries the same
- * attribute name more than once.
- *
- * Matched on {@link ValidationException#DUPLICATED_ATTRIBUTE_NAME_FOUND} rather than on the
- * message, which is the library's to change.
- *
- * @param e The failure that ended the login.
- * @return True if the assertion repeated an attribute name.
- */
- protected boolean isDuplicatedAttributeName(final SAMLException e) {
- return e instanceof final ValidationException ve && ve.getErrorCode() == ValidationException.DUPLICATED_ATTRIBUTE_NAME_FOUND;
- }
-
- /**
- * Logs the one line reported when the IdP put the same attribute name on more than one
- * element of the assertion.
- *
- * Told apart from the generic failure line because the generic one -- "Found an Attribute
- * element with duplicated Name" -- names a fact about the XML and leaves an administrator with
- * nothing to change. The setting that accepts the repeats exists, but nothing in Fess mentions
- * it, so without this line the deployment is a dead end.
- *
- * It is worth its own line because of how it is reached rather than how rare it is: this
- * refusal happens in {@code Auth#processResponse} after {@code SamlResponse#isValid} has
- * already returned true, while the attributes are being read, so the signature, the
- * InResponseTo comparison and the replay check all passed. An administrator who is told only
- * that an assertion was refused reasonably suspects the certificate or the clock, none of
- * which is involved.
- *
- * Keycloak is named because it is listed as a supported IdP and produces this on a stock
- * configuration: its role and group mappers emit one {@code } element per value
- * unless their {@code single} option is enabled, and every Keycloak account carries several
- * default realm roles, so every login of every user fails. The failure does not depend on
- * Fess mapping those attributes -- a deployment that sets no
- * {@code saml.attribute.role.name} at all fails identically, because the refusal is in the
- * library, before Fess is given anything to map.
- *
- * The pending AuthnRequest ID is not consumed by this failure, so a login retried after the
- * IdP or Fess is reconfigured still has its ID to match against.
- */
- protected void logDuplicatedAttributeName() {
- logger.warn("""
- The IdP repeated an attribute name in the SAML assertion, which is refused, so the login failed\
- while the attributes were being read; the assertion itself passed validation and no group or\
- role was mapped. An IdP that emits one element per value produces this: Keycloak\
- does unless the "single" option of its role and group mappers is enabled, and every Keycloak\
- account carries several default roles. Either aggregate each attribute into a single element\
- at the IdP, or set {}=true in system.properties to accept the repeats and merge their values.""",
- SAML_PREFIX + "security.allow_duplicated_attribute_name");
- }
-
- /**
- * Returns whether the request carries a session id that the container no longer recognises.
- *
- * This is what tells an expired session apart from a browser that is not sending the
- * cookie: a request with no session id at all cannot have lost one. Named after the
- * {@code EntraIdAuthenticator} method that answers the same question, so that the two
- * authenticators stay recognisable to each other.
- *
- * Unlike there, the answer only chooses a log line here. An unmatched SAML response is
- * refused either way, because restarting the login by redirecting to an IdP that is already
- * authenticated would only bring the same unmatched assertion straight back.
- *
- * @param request The HTTP servlet request.
- * @return True if a session id was sent and it is no longer valid.
- */
- protected boolean hasExpiredSession(final HttpServletRequest request) {
- return request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid();
- }
-
- /**
- * Records the ID of an AuthnRequest that has just been sent to the IdP, pruning the entries
- * that can no longer be answered first.
- *
- * @param session The HTTP session.
- * @param requestId The ID of the AuthnRequest sent to the IdP.
- */
- protected void storeRequestIdInSession(final HttpSession session, final String requestId) {
- final Map requestIdMap = getRequestIdMap(session);
- removeExpiredRequestIds(requestIdMap);
- removeOldestRequestIds(requestIdMap, maxRequestIds - 1);
- if (logger.isDebugEnabled()) {
- logger.debug("Storing AuthnRequest ID in session: {}", requestId);
- }
- requestIdMap.put(requestId, ComponentUtil.getSystemHelper().getCurrentTimeAsLong());
- }
-
- /**
- * Returns the per-session map of unanswered AuthnRequest IDs, creating it if needed.
- *
- * The map is concurrent, and the create is synchronized on the session, because the tabs
- * that make several logins possible in the first place can also start them at the same
- * moment: a plain {@code HashMap} created twice loses the ID one of them has to match
- * later.
- *
- * Anything else found under the key is replaced rather than cast. A session that predates
- * this change holds a bare {@link String} there, and blindly casting it would end the login
- * with a {@link ClassCastException} rather than a message; that single ID is carried over so
- * a login already in flight across the upgrade can still complete.
- *
- * @param session The HTTP session.
- * @return The AuthnRequest ID map held by the session, keyed by ID and valued with the time
- * the ID was created.
- */
- protected Map getRequestIdMap(final HttpSession session) {
- synchronized (session) {
- final Object stored = session.getAttribute(SAML_STATE);
- if (stored instanceof ConcurrentHashMap) {
- return (Map) stored;
- }
- final Map concurrentMap = new ConcurrentHashMap<>();
- if (stored instanceof final String requestId && StringUtil.isNotBlank(requestId)) {
- concurrentMap.put(requestId, ComponentUtil.getSystemHelper().getCurrentTimeAsLong());
- }
- session.setAttribute(SAML_STATE, concurrentMap);
- return concurrentMap;
- }
- }
-
- /**
- * Drops the AuthnRequest IDs that are older than the configured TTL.
- *
- * @param requestIdMap The AuthnRequest ID map to prune.
- */
- protected void removeExpiredRequestIds(final Map requestIdMap) {
- final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
- final long requestIdTtl = getRequestIdTtl();
- requestIdMap.entrySet()
- .stream()
- .filter(e -> (now - e.getValue()) / 1000L > requestIdTtl)
- .map(Map.Entry::getKey)
- .collect(Collectors.toList())
- .forEach(requestId -> {
- if (logger.isDebugEnabled()) {
- logger.debug("Removing expired AuthnRequest ID: {}", requestId);
- }
- requestIdMap.remove(requestId);
- });
- }
-
- /**
- * Drops the least recently created AuthnRequest IDs until at most {@code limit} remain. An
- * AuthnRequest that is never answered does not expire before the TTL, so this is what bounds
- * the map for a client that keeps starting logins.
- *
- * @param requestIdMap The AuthnRequest ID map to prune.
- * @param limit The number of AuthnRequest IDs to keep.
- */
- protected void removeOldestRequestIds(final Map requestIdMap, final int limit) {
- if (requestIdMap.size() <= limit) {
- return;
- }
- requestIdMap.entrySet()
- .stream()
- .sorted(Comparator.comparingLong(Map.Entry::getValue))
- .limit((long) requestIdMap.size() - limit)
- .map(Map.Entry::getKey)
- .collect(Collectors.toList())
- .forEach(requestId -> {
- if (logger.isDebugEnabled()) {
- logger.debug("Removing surplus AuthnRequest ID: {}", requestId);
- }
- requestIdMap.remove(requestId);
- });
- }
-
- /**
- * Sets the maximum number of unanswered AuthnRequest IDs kept per session.
- *
- * A value that is not positive is reported and replaced by
- * {@link #DEFAULT_MAX_REQUEST_IDS} rather than taken literally, for the same reason
- * {@link #getRequestIdTtl()} refuses one. Nothing fails as it is applied, but
- * {@link #getCandidateRequestIds} hands it to {@code limit()}: {@code 0} leaves
- * {@link #processSamlResponse} no candidate to try, so every SAML login in the deployment
- * fails and is reported by {@link #logUnmatchedSamlResponse} as the cookie problem it is not,
- * and a negative value makes {@code limit()} throw an {@link IllegalArgumentException} that
- * reaches the log only as "Authentication failed.". {@code 0} is not a far-fetched value to
- * write either -- it reads as "no limit" -- which is why it falls back rather than being
- * taken at its word. The warning names the property and the value. Refusing it here is
- * enough because this setter is the only path a configured value takes; a subclass that
- * assigns the field directly bypasses the check.
- *
- * @param maxRequestIds The maximum number of AuthnRequest IDs. Only a positive value is
- * honoured.
- */
- public void setMaxRequestIds(final int maxRequestIds) {
- if (maxRequestIds <= 0) {
- logger.warn("maxRequestIds must be a positive number: {}. Using {}.", maxRequestIds, DEFAULT_MAX_REQUEST_IDS);
- this.maxRequestIds = DEFAULT_MAX_REQUEST_IDS;
- return;
- }
- this.maxRequestIds = maxRequestIds;
- }
-
- /**
- * Gets how long an unanswered AuthnRequest ID stays usable.
- *
- * A value that is not a number is reported once per read rather than thrown, because the
- * alternative is a login that dies with a {@code NumberFormatException} nobody can act
- * on.
- *
- * A value that is not positive is treated the same way, and for the same reason. It parses,
- * so nothing would fail here, but {@link #removeExpiredRequestIds} compares
- * {@code (now - created) / 1000} against it: {@code 0} drops an AuthnRequest ID one second
- * after it was issued and a negative value drops it at once, so no IdP round trip could ever
- * complete and every SAML login in the deployment would fail. {@code 0} is not a far-fetched
- * value to write either -- it reads as "no expiry", and elsewhere in Fess that is what it
- * means -- which is why it falls back rather than being taken literally. The warning names the
- * property and the value, and is worded differently from the one above so that a log says
- * which of the two mistakes was made.
- *
- * @return The TTL in seconds, always positive. {@link #removeExpiredRequestIds} compares it
- * against an elapsed time that has already been divided by 1000.
- */
- protected long getRequestIdTtl() {
- final long defaultTtl = Long.parseLong(DEFAULT_REQUEST_ID_TTL);
- final String value = ComponentUtil.getFessConfig().getSystemProperty(SAML_REQUEST_ID_TTL);
- if (StringUtil.isBlank(value)) {
- return defaultTtl;
- }
- final long requestIdTtl;
- try {
- requestIdTtl = Long.parseLong(value.trim());
- } catch (final NumberFormatException e) {
- logger.warn("Invalid {}: {}. Using {} seconds.", SAML_REQUEST_ID_TTL, value, DEFAULT_REQUEST_ID_TTL);
- return defaultTtl;
- }
- if (requestIdTtl <= 0) {
- logger.warn("{} must be a positive number of seconds: {}. Using {} seconds.", SAML_REQUEST_ID_TTL, value,
- DEFAULT_REQUEST_ID_TTL);
- return defaultTtl;
- }
- return requestIdTtl;
- }
-
- /**
- * Returns whether the request carries a SAML response, which is what the IdP posts to the
- * assertion consumer service.
- *
- * The session is deliberately not consulted here: it is the session cookie that goes
- * missing when the browser refuses to send it on the cross-site POST, and a callback that is
- * mistaken for a fresh visit is redirected back to the IdP forever.
- *
- * Only solicited responses are accepted. Fess binds every response to the ID of the
- * AuthnRequest it sent, so an unsolicited (IdP-initiated) response has nothing to match
- * against and is rejected rather than answered with a fresh AuthnRequest.
- *
- * @param request The HTTP request.
- * @return true if the request carries a SAML response.
- */
- protected boolean containsSamlResponse(final HttpServletRequest request) {
- return StringUtil.isNotBlank(request.getParameter("SAMLResponse"));
- }
-
- /**
- * Creates a login credential.
- * @param request The HTTP request.
- * @param response The HTTP response.
- * @param auth The SAML authentication.
- * @return The login credential.
- */
- protected LoginCredential createLoginCredential(final HttpServletRequest request, final HttpServletResponse response, final Auth auth) {
- final SamlCredential samlCredential = new SamlCredential(auth);
- if (logger.isDebugEnabled()) {
- logger.debug("SamlCredential: {}", samlCredential);
- }
- return samlCredential;
- }
-
- @Override
- public void resolveCredential(final LoginCredentialResolver resolver) {
- resolver.resolve(SamlCredential.class, credential -> OptionalEntity.of(credential.getUser()));
- }
-
- @Override
- public String logout(final FessUserBean user) {
- if (user.getFessUser() instanceof final SamlUser samlUser) {
- return LaRequestUtil.getOptionalRequest().map(request -> {
- if (logger.isDebugEnabled()) {
- logger.debug("Logging out with SAML Authenticator");
- }
- final HttpServletResponse response = LaResponseUtil.getResponse();
- try {
- final Saml2Settings settings = getSettings();
- if (settings.getIdpSingleLogoutServiceUrl() == null) {
- if (logger.isDebugEnabled()) {
- logger.debug("IdP single logout service URL is not configured, skipping SLO for user: {}", samlUser);
- }
- return null;
- }
- final Auth auth = new Auth(settings, request, response);
- final LogoutRequestParams logoutRequestParams = new LogoutRequestParams(samlUser.getSessionIndex(), samlUser.getName(),
- samlUser.getNameIdFormat(), samlUser.getNameidNameQualifier(), samlUser.getNameidSPNameQualifier());
- return auth.logout(null, logoutRequestParams, true);
- } catch (final Exception e) {
- logger.warn("Failed to logout from IdP: name={}", samlUser.getName(), e);
- }
- return null;
- }).orElse(null);
- }
- return null;
- }
-
- @Override
- public ActionResponse getResponse(final SsoResponseType responseType) {
- return switch (responseType) {
- case METADATA -> getMetadataResponse();
- case LOGOUT -> getLogoutResponse();
- default -> null;
- };
- }
-
- /**
- * Builds the exception used to report a failed SSO request to the user.
- *
- * @param action The action being performed, used as the log message.
- * @param msg The reason, shown to the user.
- * @param cause The underlying cause. An {@code SsoStateException} marks the failure as caused
- * by the client, which {@code SsoAction} logs without a stack trace.
- * @return The exception to throw.
- */
- protected SsoMessageException processFailure(final String action, final String msg, final Exception cause) {
- return new SsoMessageException(messages -> messages.addErrorsFailedToProcessSsoRequest(UserMessages.GLOBAL_PROPERTY_KEY, msg),
- action, cause);
- }
-
- /**
- * Builds the exception used to report a failed SSO request that has no underlying exception.
- *
- * @param action The action being performed, used as the log message.
- * @param msg The reason, shown to the user.
- * @return The exception to throw.
- */
- protected SsoMessageException processFailure(final String action, final String msg) {
- return processFailure(action, msg, new SsoProcessException(msg));
- }
-
- /**
- * Gets the metadata response.
- *
- * The SP metadata is what the IdP is registered from, so it has to be obtainable before
- * any {@code saml.idp.*} property exists. Only the SP settings are therefore validated;
- * constructing an {@link Auth} here would validate the IdP settings in its constructor and
- * fail while they are still empty.
- *
- * @return The metadata response.
- */
- /**
- * Warns when metadata signing was asked for but cannot happen.
- *
- * {@code Saml2Settings#getSPMetadata()} signs with the SP key and certificate and swallows
- * any failure at debug level, returning the unsigned document. So with
- * {@code saml.security.sign_metadata=true} and no key material, {@code /sso/metadata} answers
- * 200 with unsigned metadata and nothing says the request was dropped. An operator who turned
- * signing on has no way to tell it is not happening.
- *
- * This is reported rather than refused: the metadata is still correct, only unsigned, and
- * an IdP that does not check the signature keeps working. Refusing would turn a working
- * deployment into a broken one on upgrade.
- *
- * @param settings The SAML settings.
- */
- protected void warnIfMetadataCannotBeSigned(final Saml2Settings settings) {
- if (settings.getSignMetadata() && (settings.getSPkey() == null || settings.getSPcert() == null)) {
- logger.warn(
- "saml.security.sign_metadata is enabled but the SP metadata cannot be signed, so it is published unsigned. "
- + "Signing needs both saml.sp.privatekey and saml.sp.x509cert; missing: {}.",
- settings.getSPkey() == null
- ? (settings.getSPcert() == null ? "saml.sp.privatekey, saml.sp.x509cert" : "saml.sp.privatekey")
- : "saml.sp.x509cert");
- }
- }
-
- protected ActionResponse getMetadataResponse() {
- return LaRequestUtil.getOptionalRequest().map(request -> {
- if (logger.isDebugEnabled()) {
- logger.debug("Accessing metadata with SAML Authenticator");
- }
- try {
- final Saml2Settings settings = getSettings();
- // checkSettings() with spValidationOnly is by definition checkSPSettings(), and
- // mutating the shared settings instance to say so would leak into every other
- // caller
- final List settingsErrors = settings.checkSPSettings();
- if (!settingsErrors.isEmpty()) {
- final String msg = String.join(", ", settingsErrors);
- throw processFailure("Failed to process metadata.", msg);
- }
- warnIfMetadataCannotBeSigned(settings);
- final String metadata = settings.getSPMetadata();
- final List errors = Saml2Settings.validateMetadata(metadata);
- if (!errors.isEmpty()) {
- final String msg = String.join(", ", errors);
- throw processFailure("Failed to process metadata.", msg);
- }
- return new StreamResponse("metadata.xml").contentType("application/samlmetadata+xml").stream(out -> {
- try (final Writer writer = new OutputStreamWriter(out.stream(), Constants.UTF_8_CHARSET)) {
- writer.write(metadata);
- }
- });
- } catch (final SsoMessageException e) {
- throw e;
- } catch (final Exception e) {
- throw processFailure("Failed to process metadata.", e.getMessage(), e);
- }
- }).orElseThrow(() -> processFailure("Failed to process metadata.", "Invalid state."));
- }
-
- /**
- * Returns whether the request carries a SAML logout message, which is what the IdP sends to
- * the single logout service.
- *
- * {@code /sso/logout} is reachable without authentication, so it also receives plain visits
- * that carry no SAML message at all. Those are not logout callbacks and must be rejected
- * before {@code Auth.processSLO} sees them, because it answers them with an exception whose
- * text describes the supported bindings rather than anything the visitor can act on.
- *
- * @param request The HTTP request.
- * @return true if the request carries a SAML logout request or response.
- */
- protected boolean containsSamlLogoutMessage(final HttpServletRequest request) {
- return StringUtil.isNotBlank(request.getParameter("SAMLRequest")) || StringUtil.isNotBlank(request.getParameter("SAMLResponse"));
- }
-
- /**
- * Gets the logout response.
- * @return The logout response.
- */
- protected ActionResponse getLogoutResponse() {
- return LaRequestUtil.getOptionalRequest(). map(request -> {
- if (logger.isDebugEnabled()) {
- logger.debug("Logging out with SAML Authenticator");
- }
- final HttpServletResponse response = LaResponseUtil.getResponse();
- try {
- if (!containsSamlLogoutMessage(request)) {
- // an anonymous request that is not a logout callback: rejected, not a fault,
- // so it carries an SsoStateException and is logged without a stack trace.
- // Checked before the configuration guard below, because otherwise a
- // deployment that leaves single logout unconfigured would answer the very
- // same anonymous visit with a stack trace per request.
- final String msg = "This endpoint expects a SAML logout message from the IdP.";
- throw processFailure("Failed to log out.", msg, new SsoStateException(msg));
- }
- final Saml2Settings settings = getSettings();
- if (settings.getIdpSingleLogoutServiceResponseUrl() == null) {
- final String msg = "IdP single logout service URL is not configured.";
- throw processFailure("Failed to log out.", msg);
- }
- final Auth auth = new Auth(settings, request, response);
- // A LogoutRequest that names somebody else must not take this session with it, but
- // it is still answered with an ordinary LogoutResponse: an error would tell an
- // unauthenticated sender whether it guessed a live session, and would leave a
- // confused-but-legitimate IdP with no way of finishing its own logout.
- //
- // A LogoutResponse never ends a session at all; see isLogoutResponse.
- final boolean keepLocalSession = isLogoutResponse(request) || isLogoutRequestForAnotherUser(request, settings);
- warnIfLogoutResponseReachedALiveLogin(request);
- // stay=true keeps java-saml from committing the servlet response itself
- final String redirectUrl = auth.processSLO(keepLocalSession, null, true);
- final List errors = auth.getErrors();
- if (!errors.isEmpty()) {
- // java-saml refused the message the sender supplied -- a replayed ID, a bad
- // signature, a missing NameID, XML that will not parse. The endpoint is
- // anonymous and, because SAML requires SameSite=none, reachable cross-site, so
- // this is a rejected request rather than a fault: an SsoStateException gets it
- // logged without a stack trace, the way getLoginCredential already treats a
- // callback it did not start.
- final String msg = String.join(", ", errors);
- throw processFailure("Failed to log out.", msg, new SsoStateException(msg));
- }
- if (StringUtil.isNotBlank(redirectUrl)) {
- // an IdP-initiated LogoutRequest: send our LogoutResponse back to the IdP
- return HtmlResponse.fromRedirectPathAsIs(redirectUrl);
- }
- throw new SsoMessageException(messages -> messages.addSuccessSsoLogout(UserMessages.GLOBAL_PROPERTY_KEY), "Logged out");
- } catch (final SsoMessageException e) {
- throw e;
- } catch (final Exception e) {
- throw processFailure("Failed to log out.", e.getMessage(), e);
- }
- }).orElseThrow(() -> processFailure("Failed to log out.", "Invalid state."));
- }
-
- /**
- * Returns whether the incoming logout message is a LogoutResponse, in which case it must not
- * end the local session.
- *
- * A LogoutResponse is the answer to a LogoutRequest this SP sent, and {@code LogoutAction}
- * has already ended the local login by the time one can arrive: it asks the SSO manager for
- * the redirect URL and then calls {@code logout()}, which invalidates the session. Whatever
- * session the answer lands on is therefore a fresh one, so invalidating it ends nothing that
- * was still running -- and that is the only thing a legitimate LogoutResponse gave up here.
- *
- * What it costs is the rest of the deployment. {@code /sso/logout} is anonymous and,
- * because SAML requires {@code SameSite=none}, reachable cross-site with the victim's session
- * cookie attached. {@code Auth#processSLO} is given no request ID to bind the answer to -- the
- * SP has none to give, having just discarded the session that would have held it -- so
- * java-saml skips the {@code InResponseTo} comparison, and with the shipped
- * {@code saml.security.want_messages_signed=false} every remaining check is conditional on an
- * attribute the sender may simply omit: {@code Issuer}, {@code Destination}, and the
- * {@code InResponseTo} attribute itself. A LogoutResponse carrying nothing but a Success
- * status was therefore enough to invalidate any session it was pointed at -- a SAML login, a
- * local one, or a login still in flight, whose pending AuthnRequest ID went with the session
- * and left the user unable to log in while the page kept firing. No signature, no guess, and
- * nothing in the log.
- *
- * This is the same exposure {@link #isLogoutRequestForAnotherUser} closes for the other
- * kind of message, where the NameID is what costs the sender a guess. A LogoutResponse carries
- * no NameID, so there is nothing to compare -- and nothing that needs comparing, because there
- * is nothing left for it to end.
- *
- * @param request The HTTP request carrying the SAML logout message.
- * @return true if the message is a LogoutResponse.
- */
- protected boolean isLogoutResponse(final HttpServletRequest request) {
- return StringUtil.isBlank(request.getParameter("SAMLRequest")) && StringUtil.isNotBlank(request.getParameter("SAMLResponse"));
- }
-
- /**
- * Reports a LogoutResponse that reached a session which is still logged in.
- *
- * The legitimate answer arrives after {@code LogoutAction} has ended the login, so this
- * says nothing on the path a logout actually takes. It says something on the one it does not:
- * a LogoutResponse aimed at a live login answers a logout that was never started, which is
- * either a stale replay out of a browser's history or a request forged to end somebody's
- * session. Reporting it is what makes the attempt visible; the session is kept either way.
- *
- * One bounded line and no stack trace, for the reason the rest of this endpoint gives: it
- * is anonymous, so a rejected message must not let an unauthenticated client fill the log.
- * Nothing the sender supplied is echoed, because nothing in the message was validated.
- *
- * @param request The HTTP request carrying the SAML logout message.
- */
- protected void warnIfLogoutResponseReachedALiveLogin(final HttpServletRequest request) {
- if (!isLogoutResponse(request) || !isLoggedIn()) {
- return;
- }
- logger.warn("A SAML LogoutResponse reached a session that is still logged in, so it is answered without ending it."
- + " A LogoutResponse answers a LogoutRequest this server sent, and this server had not sent one:"
- + " the message is a replay or was forged to end the session. An IdP that starts a logout sends a LogoutRequest instead.");
- }
-
- /**
- * Returns whether this session is logged in, answering false when that cannot be determined.
- *
- * @return true if a user is logged in.
- */
- protected boolean isLoggedIn() {
- try {
- return getSavedUserBean().isPresent();
- } catch (final Exception e) {
- // this endpoint has to keep working for a request that reaches it outside a login
- // scope, so being unable to look at the session means "cannot tell", not "fail"
- if (logger.isDebugEnabled()) {
- logger.debug("Failed to read the session user.", e);
- }
- return false;
- }
- }
-
- /**
- * Returns whether an IdP-initiated LogoutRequest names somebody other than the user this
- * session is logged in as, in which case the session must survive it.
- *
- * {@code /sso/logout} is anonymous and, because SAML requires {@code SameSite=none}, is
- * reachable cross-site with the victim's session cookie attached. With the shipped default
- * {@code saml.security.want_messages_signed=false} java-saml accepts a LogoutRequest that
- * carries no signature, and every other check it makes is conditional on an attribute the
- * sender simply omits -- {@code NotOnOrAfter}, {@code Destination}, and even {@code Issuer},
- * which the protocol schema declares optional and whose absence therefore skips the entity ID
- * comparison as well. The NameID is the one element java-saml insists on, so it is the one
- * thing left worth checking, and comparing it with the session costs an attacker the guess.
- *
- * A LogoutResponse the IdP is answering ({@code SAMLResponse}) is left alone here: it
- * carries no NameID to compare, and constructing a {@link LogoutRequest} from such a request
- * would silently build a fresh outgoing message rather than parse anything.
- * {@link #isLogoutResponse(HttpServletRequest)} keeps the session for that kind of message
- * instead, on the ground that there is nothing left for it to end.
- *
- * Anything that is not a clear mismatch keeps the previous behaviour of ending the session:
- * no user logged in, a user who did not come from SAML, a message whose NameID cannot be read
- * at all. Those are properties of this deployment or of a message java-saml is about to reject
- * anyway, not values a sender chooses.
- *
- * A NameID that is read but empty is not one of them. It is the sender's own input, so
- * treating it as "cannot tell" would hand back exactly the bypass this method exists to close:
- * java-saml requires the {@code } element to be present but does not require it to
- * carry anything, so {@code } parses, names nobody, and would end any session it
- * reached. It is therefore compared like any other value and, naming nobody, never matches. No
- * IdP is lost by this: one that ends a session says whose.
- *
- * @param request The HTTP request carrying the SAML logout message.
- * @param settings The SAML settings, used to parse the LogoutRequest the way java-saml
- * itself parses it a moment later.
- * @return true if the session must be kept because the LogoutRequest names another user.
- */
- protected boolean isLogoutRequestForAnotherUser(final HttpServletRequest request, final Saml2Settings settings) {
- if (StringUtil.isBlank(request.getParameter("SAMLRequest"))) {
- return false;
- }
- final String sessionNameId = getSessionSamlNameId();
- if (StringUtil.isBlank(sessionNameId)) {
- return false;
- }
- final String logoutRequestNameId = getLogoutRequestNameId(request, settings);
- if (logoutRequestNameId == null) {
- // the message could not be read at all; java-saml is about to fail on the same bytes
- return false;
- }
- if (isSameNameId(sessionNameId, logoutRequestNameId)) {
- return false;
- }
- logger.warn("The LogoutRequest names '{}' but this session is logged in as '{}', so it is answered without ending the session."
- + " If a legitimate single logout stopped working, compare the NameID the IdP puts in its assertion with the one it puts"
- + " in its LogoutRequest.", sanitizeForLog(logoutRequestNameId), sanitizeForLog(sessionNameId));
- return true;
- }
-
- /**
- * Bounds a NameID and strips its control characters so that it can be embedded in a log
- * message.
- *
- * The NameID of the LogoutRequest reaches this log before anything has authenticated the
- * message -- that is the whole point of the check that reports it -- so a raw newline in it
- * would let an unauthenticated sender forge log lines. It is XML text content, so it can hold
- * one. {@code SpnegoAuthenticator} bounds the realm it logs for the same reason and in the
- * same way.
- *
- * @param value The NameID to embed in a log message.
- * @return A value safe to embed in a log message.
- */
- protected static String sanitizeForLog(final String value) {
- return sanitizeForLog(value, MAX_LOGGED_NAME_ID_LENGTH);
- }
-
- /**
- * Bounds a value to {@code maxLength} and strips its control characters so that it can be
- * embedded in a log message.
- *
- * @param value The value to embed in a log message.
- * @param maxLength The number of characters to keep before truncating.
- * @return A value safe to embed in a log message.
- */
- protected static String sanitizeForLog(final String value, final int maxLength) {
- final String bounded = value.length() > maxLength ? value.substring(0, maxLength) + "..." : value;
- return LOG_UNSAFE_PATTERN.matcher(bounded).replaceAll("?");
- }
-
- /**
- * Returns the NameID this session was logged in with, or null when it did not come from SAML.
- *
- * {@code SamlUser.getName()} is that NameID rather than a display name: it is what
- * {@link #logout(FessUserBean)} passes as the {@code nameId} of the LogoutRequest it sends,
- * so the IdP is expected to name the same value when the logout starts at its end.
- *
- * @return The NameID of the session user, or null when nobody is logged in, when the user did
- * not authenticate through SAML, or when the session cannot be reached at all.
- */
- protected String getSessionSamlNameId() {
- try {
- final FessUserBean userBean = getSavedUserBean().orElse(null);
- if (userBean != null && userBean.getFessUser() instanceof final SamlUser samlUser) {
- return samlUser.getName();
- }
- } catch (final Exception e) {
- // this endpoint has to keep working for a request that reaches it outside a login
- // scope, so being unable to look at the session means "cannot tell", not "fail"
- if (logger.isDebugEnabled()) {
- logger.debug("Failed to read the session user.", e);
- }
- }
- return null;
- }
-
- /**
- * Returns the user bean held by the session.
- *
- * Separate from {@link #getSessionSamlNameId()} so that a test can decide who is logged in
- * without standing up a login scope; {@code /api/v2} does the same with its own handlers.
- *
- * @return The user bean of the session, empty when nobody is logged in.
- */
- protected OptionalThing getSavedUserBean() {
- return ComponentUtil.getFessLoginAssist().getSavedUserBean();
- }
-
- /**
- * Returns the NameID carried by the incoming LogoutRequest, or null when it cannot be read.
- *
- * The message is decoded and parsed with java-saml rather than by hand. {@code /sso/logout}
- * is anonymous, so hand-parsing the base64 {@code SAMLRequest} here would put an XML parser --
- * and therefore an XXE surface -- in front of an unauthenticated sender, whereas
- * {@code Util.base64decodedInflated} and {@code Util.loadXML} are the hardened path the
- * library uses on the same bytes a moment later. The arguments mirror
- * {@code LogoutRequest.isValid()} exactly, including the allowed key transport algorithms, so
- * an encrypted NameID is read here under the same restrictions it would be read under a moment
- * later and this adds no decryption the message was not going to get anyway.
- *
- * It parses once. Constructing a {@link LogoutRequest} to reach the decoded XML would parse
- * it a second time, because that constructor loads the document itself and then discards it,
- * and a parse that fails is not free: java-saml logs the failure with its stack trace, so each
- * extra parse of a message that will not parse writes another ~90 lines to the log. This
- * endpoint is anonymous and, because SAML requires {@code SameSite=none}, reachable cross-site
- * with the victim's cookie attached, which is exactly when this method runs -- so the second
- * parse fell on the sessions an attacker targets.
- *
- * Nothing here is allowed to abort the logout. A malformed message, an {@code EncryptedID}
- * with no SP private key configured to open it, an unreadable NameID: all of them mean "cannot
- * tell", which {@link #isLogoutRequestForAnotherUser} turns back into the previous behaviour.
- * Parsing here touches no replay cache -- only {@code isValid()} registers a message ID -- so
- * reading the NameID does not make java-saml reject its own copy as a replay.
- *
- * @param request The HTTP request carrying the LogoutRequest.
- * @param settings The SAML settings.
- * @return The NameID of the LogoutRequest, or null when it cannot be read.
- */
- protected String getLogoutRequestNameId(final HttpServletRequest request, final Saml2Settings settings) {
- final String logoutRequestMessage = request.getParameter("SAMLRequest");
- if (StringUtil.isBlank(logoutRequestMessage)) {
- // a LogoutResponse, or no SAML message at all: there is no LogoutRequest to read
- return null;
- }
- try {
- final Document document = Util.loadXML(Util.base64decodedInflated(logoutRequestMessage));
- if (document == null) {
- // Util.loadXML answers unparsable XML, and anything holding an ENTITY, with null
- return null;
- }
- return LogoutRequest.getNameId(document, settings.getSPkey(), settings.isTrimNameIds(),
- settings.getAllowedKeyTransportAlgorithms());
- } catch (final Exception e) {
- if (logger.isDebugEnabled()) {
- logger.debug("Failed to read the NameID of the LogoutRequest.", e);
- }
- return null;
- }
- }
-
- /**
- * Returns whether two NameIDs identify the same user.
- *
- * Deliberately more forgiving than {@code equals}, because the damage of the two comparisons
- * is not symmetric: a false match only leaves today's behaviour in place, while a false
- * mismatch breaks a legitimate single logout, which is silent and looks like the session simply
- * refusing to end.
- *
- * Both sides are trimmed. The NameID stored at login and the NameID of the LogoutRequest
- * are read from the text content of two different XML documents, and java-saml trims neither
- * unless {@code saml.parsing.trim_name_ids} is turned on, which Fess leaves off; an IdP that
- * pretty-prints one message and not the other would otherwise look like a different user.
- *
- * The comparison also ignores case. NameIDs that differ only in case are the same account
- * at every IdP that produces them -- an email address or a UPN -- and an IdP that normalises
- * case differently between its assertion and its LogoutRequest is a real deployment, not a
- * hypothetical one. It costs nothing to defend against: a sender who does not know the NameID
- * fails whatever the case, and one who does gains nothing from being allowed to change it.
- *
- * @param sessionNameId The NameID this session was logged in with, not blank.
- * @param logoutRequestNameId The NameID carried by the LogoutRequest, never null but possibly
- * blank, which the session NameID cannot be and so never matches.
- * @return true if both name the same user.
- */
- protected boolean isSameNameId(final String sessionNameId, final String logoutRequestNameId) {
- return sessionNameId.trim().equalsIgnoreCase(logoutRequestNameId.trim());
- }
-}
diff --git a/src/main/java/org/codelibs/fess/sso/spnego/SpnegoAuthenticator.java b/src/main/java/org/codelibs/fess/sso/spnego/SpnegoAuthenticator.java
deleted file mode 100644
index 4f469a3ce..000000000
--- a/src/main/java/org/codelibs/fess/sso/spnego/SpnegoAuthenticator.java
+++ /dev/null
@@ -1,729 +0,0 @@
-/*
- * Copyright 2012-2025 CodeLibs Project and the Others.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
- * either express or implied. See the License for the specific language
- * governing permissions and limitations under the License.
- */
-package org.codelibs.fess.sso.spnego;
-
-import java.io.File;
-import java.nio.charset.StandardCharsets;
-import java.util.Arrays;
-import java.util.Base64;
-import java.util.Enumeration;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.regex.Pattern;
-
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
-import org.codelibs.core.io.ResourceUtil;
-import org.codelibs.core.lang.StringUtil;
-import org.codelibs.fess.app.web.base.login.ActionResponseCredential;
-import org.codelibs.fess.app.web.base.login.FessLoginAssist.LoginCredentialResolver;
-import org.codelibs.fess.app.web.base.login.SpnegoCredential;
-import org.codelibs.fess.exception.SsoLoginException;
-import org.codelibs.fess.exception.SsoStateException;
-import org.codelibs.fess.sso.SsoAuthenticator;
-import org.codelibs.fess.util.ComponentUtil;
-import org.codelibs.spnego.SpnegoFilterConfig;
-import org.codelibs.spnego.SpnegoHttpFilter.Constants;
-import org.codelibs.spnego.SpnegoHttpServletResponse;
-import org.codelibs.spnego.SpnegoPrincipal;
-import org.dbflute.optional.OptionalEntity;
-import org.ietf.jgss.GSSException;
-import org.lastaflute.web.login.credential.LoginCredential;
-import org.lastaflute.web.servlet.filter.RequestLoggingFilter;
-import org.lastaflute.web.util.LaRequestUtil;
-import org.lastaflute.web.util.LaResponseUtil;
-
-import jakarta.annotation.PostConstruct;
-import jakarta.annotation.PreDestroy;
-import jakarta.servlet.FilterConfig;
-import jakarta.servlet.ServletContext;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-
-/**
- * SPNEGO (Security Provider Negotiation Protocol) authenticator implementation.
- *
- * This class provides Single Sign-On (SSO) authentication using the SPNEGO protocol,
- * which is commonly used for Kerberos-based authentication in Windows environments.
- * It handles the negotiation between client and server to establish a secure
- * authentication context without requiring users to explicitly enter credentials.
- *
- * The authenticator supports various configuration options including delegation,
- * basic authentication fallback, and localhost authentication bypass.
- */
-public class SpnegoAuthenticator implements SsoAuthenticator {
-
- /** Logger for this class. */
- private static final Logger logger = LogManager.getLogger(SpnegoAuthenticator.class);
-
- /** Configuration key for enabling delegation in SPNEGO authentication. */
- protected static final String SPNEGO_ALLOW_DELEGATION = "spnego.allow.delegation";
-
- /** Configuration key for the comma-separated list of additionally allowed Kerberos realms. */
- protected static final String SPNEGO_ALLOWED_REALMS = "spnego.allowed.realms";
-
- /** Configuration key for allowing localhost authentication bypass. */
- protected static final String SPNEGO_ALLOW_LOCALHOST = "spnego.allow.localhost";
-
- /** Configuration key for prompting NTLM authentication. */
- protected static final String SPNEGO_PROMPT_NTLM = "spnego.prompt.ntlm";
-
- /** Configuration key for allowing unsecure basic authentication. */
- protected static final String SPNEGO_ALLOW_UNSECURE_BASIC = "spnego.allow.unsecure.basic";
-
- /** Configuration key for allowing basic authentication. */
- protected static final String SPNEGO_ALLOW_BASIC = "spnego.allow.basic";
-
- /** Configuration key for pre-authentication password. */
- protected static final String SPNEGO_PREAUTH_PASSWORD = "spnego.preauth.password";
-
- /** Configuration key for pre-authentication username. */
- protected static final String SPNEGO_PREAUTH_USERNAME = "spnego.preauth.username";
-
- /** Configuration key for login server module name. */
- protected static final String SPNEGO_LOGIN_SERVER_MODULE = "spnego.login.server.module";
-
- /** Configuration key for login client module name. */
- protected static final String SPNEGO_LOGIN_CLIENT_MODULE = "spnego.login.client.module";
-
- /** Configuration key for Kerberos configuration file path. */
- protected static final String SPNEGO_KRB5_CONF = "spnego.krb5.conf";
-
- /** Configuration key for login configuration file path. */
- protected static final String SPNEGO_LOGIN_CONF = "spnego.login.conf";
-
- /** Configuration key for SPNEGO logger level. */
- protected static final String SPNEGO_LOGGER_LEVEL = "spnego.logger.level";
-
- /** Upper bound on the length of a client-supplied value embedded in a log message. */
- protected static final int MAX_LOGGED_REALM_LENGTH = 64;
-
- /**
- * Characters that must not be copied verbatim into a log message. {@code \p{Cntrl}} alone is
- * ASCII-only, so the Unicode break characters a log viewer still renders as a new line are
- * listed explicitly.
- */
- private static final Pattern LOG_UNSAFE_PATTERN = Pattern.compile("[\\p{Cntrl}\\u0085\\u2028\\u2029]");
-
- /** The underlying SPNEGO authenticator instance. */
- protected volatile org.codelibs.spnego.SpnegoAuthenticator authenticator = null;
-
- /**
- * Constructs a new SPNEGO authenticator.
- */
- public SpnegoAuthenticator() {
- // do nothing
- }
-
- /**
- * Initializes the SPNEGO authenticator and registers it with the SSO manager.
- * This method is called automatically after dependency injection is complete.
- */
- @PostConstruct
- public void init() {
- if (logger.isDebugEnabled()) {
- logger.debug("Initializing {}", this.getClass().getSimpleName());
- }
- ComponentUtil.getSsoManager().register(this);
- }
-
- /**
- * Releases the SPNEGO server credentials and login context on shutdown.
- */
- @PreDestroy
- public synchronized void destroy() {
- if (authenticator != null) {
- try {
- authenticator.dispose();
- } catch (final Exception e) {
- logger.warn("Failed to dispose SPNEGO authenticator.", e);
- } finally {
- authenticator = null;
- }
- }
- }
-
- /**
- * Gets or creates the SPNEGO authenticator instance.
- *
- * This method implements lazy initialization with synchronization to ensure
- * the authenticator is only created once per JVM. Because the underlying
- * SpnegoFilterConfig is a JVM-wide singleton, the configuration is cached for
- * the lifetime of the process and a Fess restart is required to apply changes.
- *
- * @return The configured SPNEGO authenticator instance
- * @throws SsoLoginException if SPNEGO initialization fails
- */
- protected org.codelibs.spnego.SpnegoAuthenticator getAuthenticator() {
- final org.codelibs.spnego.SpnegoAuthenticator current = authenticator;
- if (current != null) {
- return current;
- }
- synchronized (this) {
- if (authenticator != null) {
- return authenticator;
- }
- try {
- // NOTE: The underlying SpnegoFilterConfig is a JVM-wide singleton, so the SPNEGO
- // configuration is effectively cached for the lifetime of the process. Changes to the
- // spnego.* settings therefore require a Fess restart to take effect.
- final SpnegoConfig spnegoConfig = new SpnegoConfig();
- final SpnegoFilterConfig config = SpnegoFilterConfig.getInstance(spnegoConfig);
- authenticator = new org.codelibs.spnego.SpnegoAuthenticator(config);
- // Warn only once initialization has succeeded. A failed attempt leaves authenticator
- // null and is retried on the next login, so warning before this point repeats the
- // same message for every attempt, and the settings cannot matter until SPNEGO runs.
- warnInsecureSettings(spnegoConfig);
- return authenticator;
- } catch (final Exception e) {
- throw new SsoLoginException("Failed to initialize SPNEGO.", e);
- }
- }
- }
-
- /**
- * Logs a warning for security-sensitive settings that are effectively enabled.
- *
- * The coded defaults for these settings are secure, but they only apply when the key is absent
- * from the system properties. An instance that stored the old, permissive values before the
- * defaults were hardened keeps using them silently, so surface them at initialization time.
- *
- * @param config the resolved SPNEGO configuration
- */
- protected void warnInsecureSettings(final SpnegoConfig config) {
- if (Boolean.parseBoolean(config.getInitParameter(Constants.ALLOW_LOCALHOST))) {
- logger.warn("spnego.allow.localhost=true: same-host requests are authenticated as the server OS user "
- + "without any Kerberos verification. Set it to false unless you fully understand the risk.");
- }
- if (Boolean.parseBoolean(config.getInitParameter(Constants.ALLOW_BASIC))
- && Boolean.parseBoolean(config.getInitParameter(Constants.ALLOW_UNSEC_BASIC))) {
- logger.warn(
- "spnego.allow.unsecure.basic=true: basic credentials may be sent over plain HTTP. " + "Set it to false and use HTTPS.");
- }
- }
-
- /**
- * Attempts to obtain login credentials using SPNEGO authentication.
- *
- * This method processes the HTTP request to extract and validate SPNEGO
- * authentication tokens. It handles the SPNEGO handshake process and
- * extracts the user principal from successful authentication.
- *
- * @return The login credential containing the authenticated username,
- * an ActionResponseCredential for authentication challenges,
- * or null if no authentication information is available
- * @throws SsoLoginException if SPNEGO authentication fails
- */
- @Override
- public LoginCredential getLoginCredential() {
- return LaRequestUtil.getOptionalRequest().map(request -> {
- if (logger.isDebugEnabled()) {
- logger.debug("Logging in with SPNEGO Authenticator");
- }
- final HttpServletResponse response = LaResponseUtil.getResponse();
- final SpnegoHttpServletResponse spnegoResponse = new SpnegoHttpServletResponse(response);
-
- // The Basic path destroys the realm before a principal exists, so it has to be checked
- // here, against the request. Doing it before authenticating also keeps a rejected realm
- // from causing an AS-REQ to a foreign KDC.
- rejectDisallowedBasicRealm(request);
-
- // client/caller principal
- final SpnegoPrincipal principal;
- try {
- principal = getAuthenticator().authenticate(request, spnegoResponse);
- if (logger.isDebugEnabled()) {
- logger.debug("principal={}", principal);
- }
- } catch (final Exception e) {
- final String msg = "Failed to process Authorization Header: " + maskAuthzHeader(request.getHeader(Constants.AUTHZ_HEADER));
- if (logger.isDebugEnabled()) {
- // Carries the exception, so the stack trace a refusal no longer writes at warn
- // level is still one log level away when an SSO failure has to be diagnosed.
- logger.debug(msg, e);
- }
- // The library reports why the handshake failed; keep it, but not every exception
- // carries a message and "null " helps nobody diagnose an SSO failure.
- final String detail = e.getMessage();
- final String reason = detail == null ? msg : detail + " " + msg;
- if (isHandshakeRefusal(e)) {
- throw new SsoStateException(reason, e);
- }
- throw new SsoLoginException(reason, e);
- }
-
- // context/auth loop not yet complete
- final boolean status = spnegoResponse.isStatusSet();
- if (logger.isDebugEnabled()) {
- logger.debug("isStatusSet={}", status);
- }
- if (status) {
- // The library has already written and flushed the 401 with its WWW-Authenticate header,
- // so this exception only unwinds the action. Log it at debug level to keep the normal
- // SPNEGO handshake out of the application log.
- return new ActionResponseCredential(() -> {
- throw new RequestLoggingFilter.RequestClientErrorException("Your request is not authorized.", "401 Unauthorized",
- HttpServletResponse.SC_UNAUTHORIZED).asLogging(RequestLoggingFilter.DelicateErrorLoggingLevel.DEBUG);
- });
- }
-
- // assert
- if (null == principal) {
- final String msg = "Principal was null.";
- if (logger.isDebugEnabled()) {
- logger.debug(msg);
- }
- throw new SsoLoginException(msg);
- }
-
- final String[] username = principal.getName().split("@", 2);
- if (logger.isDebugEnabled()) {
- logger.debug("username={}", Arrays.toString(username));
- }
- if (username.length == 2 && StringUtil.isNotBlank(username[1]) && !isAllowedRealm(username[1])) {
- // A refused realm is a rejected request, not a fault: /sso is anonymous, so a stack
- // trace per attempt would let an unauthenticated client fill the log.
- throw new SsoStateException(realmRejectedMessage(username[1]));
- }
- return new SpnegoCredential(username[0]);
- }).orElse(null);
-
- }
-
- /**
- * Tells whether a failed handshake is a request this server refused rather than a fault it
- * suffered, so that it is reported by message instead of by stack trace.
- *
- * {@code /sso} is anonymous, so whatever one rejected request writes to the log is what an
- * unbounded loop of them writes. Three kinds of failure arrive here and all three are decided
- * by what the client sent:
- *
- *
- * {@link UnsupportedOperationException} -- a header the library refuses to even try: a
- * scheme that is neither Negotiate nor Basic, a Basic header carrying no token, Basic while
- * basic authentication is not supported, or an NTLM token it cannot downgrade.
- * {@link IllegalArgumentException} -- the token itself:
- * {@code SpnegoProvider#parseAuthHeader} does not validate it, so the strict Base64 decoder
- * behind {@code SpnegoAuthScheme#getToken} rejects it, and that decode runs at the top of
- * {@code SpnegoProvider#negotiate} before any scheme dispatch -- so {@code "Negotiate ###"}
- * reaches it whatever the {@code spnego.allow.*} settings say. A decoded Basic token with no
- * {@code ':'} raises it from {@code doBasicAuth} as well.
- * {@link GSSException} -- the token decoded but the acceptor would not take it: not a GSS
- * structure at all, a replayed authenticator, a ticket for another service, clock skew.
- *
- *
- * The first two already reported by message. The third did not, and it is the cheapest of
- * the three to provoke: a token of three Base64 characters decodes successfully and then fails
- * inside {@code acceptSecContext}, which cost about ninety lines of stack per request -- some
- * two orders of magnitude more than the same request without an {@code Authorization} header.
- *
- *
A misconfigured server also surfaces from {@code authenticate()} as {@code GSSException}
- * -- a broken keytab, a wrong SPN -- and those keep being reported, by the message that names
- * them ("Cannot find key of appropriate type to decrypt AP-REQ", "Checksum failed"). What they
- * lose is a stack trace whose frames are the same JDK GSS internals whatever the cause, so it
- * never distinguished a server fault from a client one; the message always did, and it is
- * still logged at warn level for every refusal. The trace itself remains at debug level.
- *
- *
A genuine fault of this server cannot arrive as any of the three. Initialization failures
- * are wrapped by {@link #getAuthenticator()} in a plain {@code SsoLoginException}, which is a
- * {@code FessSystemException} and therefore none of these types, and it keeps its stack trace.
- *
- * @param e the exception the handshake failed with
- * @return true when the client determined the failure
- */
- protected boolean isHandshakeRefusal(final Exception e) {
- return e instanceof UnsupportedOperationException || e instanceof IllegalArgumentException || e instanceof GSSException;
- }
-
- /**
- * Rejects a Basic authentication attempt whose user name names a Kerberos realm that is not
- * allowed.
- *
- * The SPNEGO handshake carries the client's real realm in the principal, so it can be validated
- * after the fact. Basic authentication cannot: the library authenticates the name the user
- * typed but then builds the principal from the server realm, and KerberosPrincipal
- * collapses the resulting two-realm name back to that server realm. By the time a principal
- * exists the typed realm is gone, which would leave the allow list unable to govern this path.
- *
- * @param request the current request
- * @throws SsoStateException if the realm named in the header is not allowed
- */
- protected void rejectDisallowedBasicRealm(final HttpServletRequest request) {
- final String realm = getBasicRealm(request.getHeader(Constants.AUTHZ_HEADER));
- if (realm != null && !isAllowedRealm(realm)) {
- // A refused realm is a rejected request, not a fault: /sso is anonymous, so a stack
- // trace per attempt would let an unauthenticated client fill the log.
- throw new SsoStateException(realmRejectedMessage(realm));
- }
- }
-
- /**
- * Builds the message reported when a Kerberos realm is refused.
- *
- * @param realm the rejected realm
- * @return the message, with the realm sanitized for logging
- */
- protected static String realmRejectedMessage(final String realm) {
- return "Kerberos realm is not allowed: realm=" + sanitizeForLog(realm) + ". Add it to " + SPNEGO_ALLOWED_REALMS
- + " to accept logins from this realm.";
- }
-
- /**
- * Extracts the Kerberos realm from the user name of a Basic {@code Authorization} header.
- *
- * Only the user name half of the decoded token is inspected. The password is never returned and
- * never logged.
- *
- * @param authzHeader the raw Authorization header value (may be null)
- * @return the realm the client typed, or null when the header is not Basic, cannot be decoded,
- * or names no realm
- */
- protected static String getBasicRealm(final String authzHeader) {
- if (authzHeader == null) {
- return null;
- }
- // The scheme is separated from the token exactly the way SpnegoProvider#parseAuthHeader
- // separates it: the scheme is matched case-insensitively at offset 0, any run of whitespace
- // after it is skipped, and the trimmed remainder is the token. Diverging from that -- by
- // splitting on a literal space, for instance -- leaves headers the library still
- // authenticates (a tab as the separator, or no separator at all) resolving to no realm
- // here, which silently reopens the spnego.allowed.realms bypass this check exists to close.
- final int schemeLength = Constants.BASIC_HEADER.length();
- if (authzHeader.length() < schemeLength || !authzHeader.regionMatches(true, 0, Constants.BASIC_HEADER, 0, schemeLength)) {
- return null;
- }
- int index = schemeLength;
- while (index < authzHeader.length() && Character.isWhitespace(authzHeader.charAt(index))) {
- index++;
- }
- if (index >= authzHeader.length()) {
- return null;
- }
- final String token = authzHeader.substring(index).trim();
- if (token.isEmpty()) {
- return null;
- }
- final byte[] decoded;
- try {
- decoded = Base64.getDecoder().decode(token);
- } catch (final IllegalArgumentException e) {
- // A malformed token is the library's to reject; do not turn it into a realm failure.
- return null;
- }
- final String credentials = new String(decoded, StandardCharsets.UTF_8);
- final int colon = credentials.indexOf(':');
- final String user = colon < 0 ? credentials : credentials.substring(0, colon);
- // The library drops a NetBIOS "DOMAIN\" prefix before authenticating, so mirror it here.
- final String name = user.substring(user.indexOf('\\') + 1);
- // Kerberos reads the realm after the last '@', not the first: KerberosPrincipal collapses
- // "alice@a@PARTNER.EXAMPLE" to name "alice@PARTNER.EXAMPLE" in realm "PARTNER.EXAMPLE", and
- // the library hands the typed name straight to the login module, so PARTNER.EXAMPLE is the
- // realm an AS-REQ would actually reach. Splitting on the first '@' names a realm that
- // exists nowhere and that the allow list can therefore only refuse.
- final int at = name.lastIndexOf('@');
- // A name ending in '@' names an empty realm, which KerberosPrincipal rejects outright, so
- // there is nothing here for the allow list to decide.
- if (at < 0 || at == name.length() - 1) {
- return null;
- }
- return name.substring(at + 1);
- }
-
- /**
- * Bounds a client-supplied value and strips its control characters.
- *
- * A realm refused on the Basic path comes straight from an unauthenticated request and is
- * written to the application log, so a raw newline would let a client forge log lines.
- *
- * @param value the client-supplied value
- * @return a value safe to embed in a log message
- */
- protected static String sanitizeForLog(final String value) {
- final String bounded = value.length() > MAX_LOGGED_REALM_LENGTH ? value.substring(0, MAX_LOGGED_REALM_LENGTH) + "..." : value;
- return LOG_UNSAFE_PATTERN.matcher(bounded).replaceAll("?");
- }
-
- /**
- * Masks an Authorization header so that only its authentication scheme remains.
- *
- * The credential part must never reach the log: a Basic token carries the user name and the
- * password, and even a short prefix of it decodes back to readable characters. The scheme that
- * survives is still client-controlled, so it is bounded and sanitized like any other value
- * taken from the request. The scheme ends at the first whitespace, matching how the library and
- * {@link #getBasicRealm(String)} split the header.
- *
- * @param authzHeader the raw Authorization header value (may be null)
- * @return the scheme followed by a mask, or "null" when the header is absent
- */
- protected static String maskAuthzHeader(final String authzHeader) {
- if (authzHeader == null) {
- return "null";
- }
- int index = 0;
- while (index < authzHeader.length() && !Character.isWhitespace(authzHeader.charAt(index))) {
- index++;
- }
- // Nothing before the first whitespace, or no whitespace at all, means no scheme can be
- // named without echoing part of the credential.
- if (index == 0 || index == authzHeader.length()) {
- return "***";
- }
- return sanitizeForLog(authzHeader.substring(0, index)) + " ***";
- }
-
- /**
- * SPNEGO filter configuration implementation.
- *
- * This inner class provides configuration parameters for the SPNEGO filter,
- * mapping system properties to SPNEGO configuration values. It handles
- * various authentication settings including Kerberos configuration,
- * authentication modules, and security options.
- */
- protected static class SpnegoConfig implements FilterConfig {
-
- /**
- * Constructs a new SPNEGO filter configuration.
- */
- public SpnegoConfig() {
- // do nothing
- }
-
- /**
- * Gets the filter name for this SPNEGO configuration.
- *
- * @return The fully qualified class name of SpnegoAuthenticator
- */
- @Override
- public String getFilterName() {
- return SpnegoAuthenticator.class.getName();
- }
-
- /**
- * Gets the servlet context. This operation is not supported.
- *
- * @return Never returns, always throws UnsupportedOperationException
- * @throws UnsupportedOperationException Always thrown as this operation is not supported
- */
- @Override
- public ServletContext getServletContext() {
- throw new UnsupportedOperationException("getServletContext() is not supported in SpnegoConfig");
- }
-
- /**
- * Gets the initialization parameter value for the given parameter name.
- *
- * This method maps SPNEGO configuration parameter names to their corresponding
- * values from system properties or default values. It handles various
- * authentication and security settings for SPNEGO.
- *
- * @param name The name of the initialization parameter
- * @return The parameter value, or null if not found
- */
- @Override
- public String getInitParameter(final String name) {
- switch (name) {
- case Constants.LOGGER_LEVEL: {
- final String logLevel = getProperty(SPNEGO_LOGGER_LEVEL, StringUtil.EMPTY);
- if (StringUtil.isNotBlank(logLevel)) {
- if (isSupportedLoggerLevel(logLevel)) {
- return logLevel;
- }
- logger.warn("Invalid spnego.logger.level (must be 0-7): {}. Falling back to auto-detection.", logLevel);
- }
- if (logger.isDebugEnabled()) {
- return "3";
- }
- if (logger.isInfoEnabled()) {
- return "5";
- }
- if (logger.isWarnEnabled()) {
- return "6";
- }
- // The library maps every unknown level (including "0") to INFO, so "7" (SEVERE) is
- // the quietest setting it actually understands.
- return "7";
- }
- case Constants.LOGIN_CONF:
- return getResourcePath(getProperty(SPNEGO_LOGIN_CONF, "auth_login.conf"));
- case Constants.KRB5_CONF:
- return getResourcePath(getProperty(SPNEGO_KRB5_CONF, "krb5.conf"));
- case Constants.CLIENT_MODULE:
- return getProperty(SPNEGO_LOGIN_CLIENT_MODULE, "spnego-client");
- case Constants.SERVER_MODULE:
- return getProperty(SPNEGO_LOGIN_SERVER_MODULE, "spnego-server");
- case Constants.PREAUTH_USERNAME:
- // Empty by default so that keytab-based server login is used when the server login
- // module is configured for it (the library only uses a keytab when both preauth
- // username and password are empty).
- return getProperty(SPNEGO_PREAUTH_USERNAME, StringUtil.EMPTY);
- case Constants.PREAUTH_PASSWORD:
- return getProperty(SPNEGO_PREAUTH_PASSWORD, StringUtil.EMPTY);
- case Constants.ALLOW_BASIC:
- // SECURITY NOTE: Basic authentication is enabled by default for compatibility.
- // For production, consider setting spnego.allow.basic to false.
- return getProperty(SPNEGO_ALLOW_BASIC, "true");
- case Constants.ALLOW_UNSEC_BASIC:
- // SECURITY: unsecure basic authentication is disabled by default so that basic
- // credentials are never offered over plain HTTP. When false, basic auth is only
- // offered over HTTPS. Enable only if you fully understand the risk.
- return getProperty(SPNEGO_ALLOW_UNSECURE_BASIC, "false");
- case Constants.PROMPT_NTLM:
- return getProperty(SPNEGO_PROMPT_NTLM, "true");
- case Constants.ALLOW_LOCALHOST:
- // SECURITY: localhost bypass is disabled by default. When enabled, the spnego library
- // authenticates same-host requests as the server OS user without Kerberos verification,
- // which is unsafe behind a same-host reverse proxy. Opt in explicitly if required.
- return getProperty(SPNEGO_ALLOW_LOCALHOST, "false");
- case Constants.ALLOW_DELEGATION:
- return getProperty(SPNEGO_ALLOW_DELEGATION, "false");
- case null:
- default:
- break;
- }
- // NOTE: spnego.exclude.dirs is deliberately not mapped. Only SpnegoHttpFilter consumes it,
- // and Fess calls SpnegoAuthenticator#authenticate directly instead of installing that
- // filter, so honoring the key here would advertise an exclusion that never happens.
- return null;
- }
-
- /**
- * Determines whether a configured logger level is one the SPNEGO library can consume.
- *
- * The library parses the value with {@link Integer#parseInt(String)}, so a value that only
- * looks numeric still fails initialization once it overflows an int. Anything it does not
- * recognize is mapped to INFO, which makes the documented 0-7 range the useful bound.
- *
- * @param value The configured logger level (not blank)
- * @return true if the value can be handed to the library
- */
- protected static boolean isSupportedLoggerLevel(final String value) {
- try {
- final int level = Integer.parseInt(value);
- return level >= 0 && level <= 7;
- } catch (final NumberFormatException e) {
- return false;
- }
- }
-
- /**
- * Gets a system property value with a default fallback.
- *
- * A blank value is treated as unset. The admin screen writes every spnego.* key on save, so
- * clearing an input field stores an empty string rather than removing the key, and passing
- * that empty string down to the library turns a simple misconfiguration into an opaque
- * initialization failure.
- *
- * @param key The property key to look up
- * @param defaultValue The default value to return if the property is not set or blank
- * @return The property value or the default value
- */
- protected String getProperty(final String key, final String defaultValue) {
- final String value = ComponentUtil.getSystemProperties().getProperty(key);
- if (StringUtil.isBlank(value)) {
- return defaultValue;
- }
- return value;
- }
-
- /**
- * Resolves a resource path to an absolute file path.
- *
- * @param path The resource path to resolve
- * @return The resolved absolute file path of the resource
- * @throws SsoLoginException if the file cannot be found
- */
- protected String getResourcePath(final String path) {
- final File file = ResourceUtil.getResourceAsFileNoException(path);
- if (file != null) {
- return file.getAbsolutePath();
- }
- throw new SsoLoginException("SPNEGO configuration file not found: " + path);
- }
-
- /**
- * Gets the names of all initialization parameters. This operation is not supported.
- *
- * @return Never returns, always throws UnsupportedOperationException
- * @throws UnsupportedOperationException Always thrown as this operation is not supported
- */
- @Override
- public Enumeration getInitParameterNames() {
- throw new UnsupportedOperationException("getInitParameterNames() is not supported in SpnegoConfig");
- }
-
- }
-
- /**
- * Determines whether the given Kerberos realm is permitted to log in.
- * The server's own realm is always allowed.
- *
- * @param realm the Kerberos realm extracted from the client principal
- * @return true if the realm is allowed
- */
- protected boolean isAllowedRealm(final String realm) {
- return isAllowedRealm(realm, getAuthenticator().getServerRealm());
- }
-
- /**
- * Determines whether the given Kerberos realm is permitted, considering the server realm and
- * the comma-separated {@code spnego.allowed.realms} system property (for intentional cross-realm
- * trust setups). When neither the server realm nor an allow list can be determined, the realm is
- * accepted to preserve backward compatibility (a warning is logged).
- *
- * @param realm the Kerberos realm extracted from the client principal
- * @param serverRealm the Kerberos realm of the SPNEGO server principal (may be blank)
- * @return true if the realm is allowed
- */
- protected boolean isAllowedRealm(final String realm, final String serverRealm) {
- final Set allowedRealms = new HashSet<>();
- if (StringUtil.isNotBlank(serverRealm)) {
- allowedRealms.add(serverRealm);
- }
- final String configured = ComponentUtil.getSystemProperties().getProperty(SPNEGO_ALLOWED_REALMS, StringUtil.EMPTY);
- if (StringUtil.isNotBlank(configured)) {
- for (final String r : configured.split(",")) {
- if (StringUtil.isNotBlank(r)) {
- allowedRealms.add(r.trim());
- }
- }
- }
- if (allowedRealms.isEmpty()) {
- logger.warn("No allowed Kerberos realm could be determined; accepting realm={} without validation.", sanitizeForLog(realm));
- return true;
- }
- return allowedRealms.stream().anyMatch(r -> r.equalsIgnoreCase(realm));
- }
-
- /**
- * Resolves the SPNEGO credential to a user entity.
- *
- * This method handles the resolution of SPNEGO credentials by checking
- * if the user is an admin user or needs to be authenticated through LDAP.
- *
- * @param resolver The credential resolver to use for user lookup
- */
- @Override
- public void resolveCredential(final LoginCredentialResolver resolver) {
- resolver.resolve(SpnegoCredential.class, credential -> {
- final String username = credential.getUserId();
- if (!ComponentUtil.getFessConfig().isAdminUser(username)) {
- return ComponentUtil.getLdapManager().login(username);
- }
- return OptionalEntity.empty();
- });
- }
-
-}
diff --git a/src/main/resources/fess_sso++.xml b/src/main/resources/fess_sso++.xml
deleted file mode 100644
index 0c79071f3..000000000
--- a/src/main/resources/fess_sso++.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/main/resources/fess_sso.xml b/src/main/resources/fess_sso.xml
index 6f2121732..c497bb0eb 100644
--- a/src/main/resources/fess_sso.xml
+++ b/src/main/resources/fess_sso.xml
@@ -2,6 +2,22 @@
+
diff --git a/src/test/java/org/codelibs/fess/app/web/base/login/EntraIdUserPermissionTest.java b/src/test/java/org/codelibs/fess/app/web/base/login/EntraIdUserPermissionTest.java
deleted file mode 100644
index ce45e8d40..000000000
--- a/src/test/java/org/codelibs/fess/app/web/base/login/EntraIdUserPermissionTest.java
+++ /dev/null
@@ -1,621 +0,0 @@
-/*
- * Copyright 2012-2025 CodeLibs Project and the Others.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
- * either express or implied. See the License for the specific language
- * governing permissions and limitations under the License.
- */
-package org.codelibs.fess.app.web.base.login;
-
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Date;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.concurrent.atomic.AtomicReference;
-
-import org.codelibs.fess.app.web.base.login.EntraIdCredential.EntraIdUser;
-import org.codelibs.fess.helper.ActivityHelper;
-import org.codelibs.fess.helper.SystemHelper;
-import org.codelibs.fess.mylasta.action.FessUserBean;
-import org.codelibs.fess.sso.entraid.EntraIdAuthenticator;
-import org.codelibs.fess.unit.UnitFessTestCase;
-import org.codelibs.fess.util.ComponentUtil;
-import org.dbflute.optional.OptionalThing;
-import org.junit.jupiter.api.Test;
-
-import com.microsoft.aad.msal4j.IAccount;
-import com.microsoft.aad.msal4j.IAuthenticationResult;
-import com.microsoft.aad.msal4j.ITenantProfile;
-import com.nimbusds.jwt.JWTClaimsSet;
-import com.nimbusds.jwt.PlainJWT;
-
-public class EntraIdUserPermissionTest extends UnitFessTestCase {
-
- /** The user's object id in the tenant, as the ID token's {@code oid} claim carries it. */
- private static final String OBJECT_ID = "3f7a1c9e-0b52-4d18-9a6c-2e5b8d41f0aa";
-
- /** An ID token carrying {@link #OBJECT_ID}, in the shape MSAL4J hands back. */
- private static final String ID_TOKEN = new PlainJWT(new JWTClaimsSet.Builder().claim("oid", OBJECT_ID).build()).serialize();
-
- private static IAuthenticationResult authResult() {
- return authResult(new Date(Long.MAX_VALUE), "access-token");
- }
-
- private static IAuthenticationResult authResult(final Date expiresOn, final String accessToken) {
- return authResult(expiresOn, accessToken, ID_TOKEN);
- }
-
- private static IAuthenticationResult authResult(final Date expiresOn, final String accessToken, final String idToken) {
- final IAccount account = new IAccount() {
- private static final long serialVersionUID = 1L;
-
- @Override
- public String homeAccountId() {
- return "home-account-id";
- }
-
- @Override
- public String environment() {
- return "login.microsoftonline.com";
- }
-
- @Override
- public String username() {
- return "taro@contoso.onmicrosoft.com";
- }
-
- @Override
- public Map getTenantProfiles() {
- return Collections.emptyMap();
- }
- };
- return new IAuthenticationResult() {
- private static final long serialVersionUID = 1L;
-
- @Override
- public String accessToken() {
- return accessToken;
- }
-
- @Override
- public String idToken() {
- return idToken;
- }
-
- @Override
- public IAccount account() {
- return account;
- }
-
- @Override
- public ITenantProfile tenantProfile() {
- return null;
- }
-
- @Override
- public String environment() {
- return "login.microsoftonline.com";
- }
-
- @Override
- public String scopes() {
- return "https://graph.microsoft.com/.default";
- }
-
- @Override
- public Date expiresOnDate() {
- return expiresOn;
- }
- };
- }
-
- /**
- * Builds an EntraIdUser without letting its constructor talk to Microsoft Graph.
- */
- private EntraIdUser newUser() {
- return newUser(authResult());
- }
-
- private EntraIdUser newUser(final IAuthenticationResult authResult) {
- ComponentUtil.register(new EntraIdAuthenticator() {
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- // the test drives setGroups/setRoles itself
- }
- }, EntraIdAuthenticator.class.getCanonicalName());
- return new EntraIdUser(authResult);
- }
-
- @Test
- public void test_getPermissions_doesNotCollapseAGroupNameOnABackslash() {
- // getCanonicalLdapName drops everything up to the first backslash, because a name a user
- // types at login may be NetBIOS-qualified as DOMAIN\name. An identity provider's group
- // name is not, so running it through that truncation let one group's name produce another
- // group's permission: with entraid.permission.fields=displayName, a tenant user who can
- // create a security group -- the Entra ID default -- names it "x\finance" and receives
- // the permission of the unrelated group "finance", and with it every document that group
- // can read. Verified end to end against a live tenant before this fix.
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final EntraIdUser user = newUser();
- user.setGroups(new String[] { "x\\finance" });
- user.setRoles(new String[] { "y\\admin" });
-
- final List permissions = Arrays.asList(user.getPermissions());
-
- assertTrue(permissions.toString(), permissions.contains("2x\\finance"));
- assertFalse(permissions.toString(), permissions.contains("2finance"));
- assertTrue(permissions.toString(), permissions.contains("Ry\\admin"));
- assertFalse(permissions.toString(), permissions.contains("Radmin"));
- }
-
- @Test
- public void test_getPermissions_doesNotCollapseAUserNameOnABackslash() {
- // The same truncation applied to the name the provider asserted for the user itself.
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final EntraIdUser user = newUser();
- user.setGroups(new String[0]);
- user.setRoles(new String[0]);
-
- final List permissions = Arrays.asList(user.getPermissions());
-
- // The account username is taro@contoso.onmicrosoft.com, so nothing is truncated here; the
- // assertion that matters is that the value arrives whole and prefixed.
- assertTrue(permissions.toString(), permissions.contains("1taro@contoso.onmicrosoft.com"));
- assertTrue(permissions.toString(), permissions.contains("1" + OBJECT_ID));
- }
-
- @Test
- public void test_getPermissions_namesTheUserByTheObjectIdInTheIdToken() {
- // Microsoft Graph names a user by the object id, so that is the value a crawler writes
- // into the role field of a document the user owns. homeAccountId() is MSAL4J's own
- // account key -- "." -- and a permission built from it matches no
- // such role, so the user never saw a document granted to them by object id.
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final EntraIdUser user = newUser();
- user.setGroups(new String[0]);
- user.setRoles(new String[0]);
-
- final List permissions = Arrays.asList(user.getPermissions());
-
- assertTrue(permissions.toString(), permissions.contains("1" + OBJECT_ID));
- assertFalse(permissions.toString(), permissions.contains("1home-account-id"));
- }
-
- @Test
- public void test_getPermissions_grantsNoObjectIdPermissionWhenTheIdTokenCarriesNone() {
- // A missing or unreadable oid claim must drop the permission, not encode a literal "null"
- // -- which is not blank, so the trailing filter would keep it, and a document whose role
- // field held it would be readable by every such session.
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final EntraIdUser user = newUser(authResult(new Date(Long.MAX_VALUE), "access-token", "not-a-jwt"));
- user.setGroups(new String[0]);
- user.setRoles(new String[0]);
-
- final List permissions = Arrays.asList(user.getPermissions());
-
- assertFalse(permissions.toString(), permissions.stream().anyMatch(p -> p.contains("null")));
- assertTrue(permissions.toString(), permissions.contains("1taro@contoso.onmicrosoft.com"));
- }
-
- @Test
- public void test_getPermissions_doesNotPinAStaleValueWhenTheAsyncLookupLands() throws Exception {
- // The membership resolution scheduled at login runs on a TimeoutManager thread while the
- // user is already logged in and searching. getPermissions() is a check-then-act -- read
- // `permissions == null`, read `groups`, write `permissions` -- so a reader that started
- // before that task lands can finish after it and overwrite the fresh value with one
- // computed from the direct groups alone. Nothing sets `permissions` back to null after
- // that, so the parent group permissions stay missing for the rest of the session.
- final CountDownLatch readerIsInside = new CountDownLatch(1);
- final CountDownLatch asyncTaskIsDone = new CountDownLatch(1);
- ComponentUtil.register(new SystemHelper() {
- @Override
- public String getSearchRoleByDirectoryGroup(final String name) {
- if ("direct-group".equals(name)) {
- // The reader has read `groups` and is now mid-computation.
- readerIsInside.countDown();
- try {
- asyncTaskIsDone.await(10L, TimeUnit.SECONDS);
- } catch (final InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
- return super.getSearchRoleByDirectoryGroup(name);
- }
- }, "systemHelper");
-
- final EntraIdUser user = newUser();
- user.setGroups(new String[] { "direct-group" });
- user.setRoles(new String[0]);
-
- final Thread reader = new Thread(() -> user.getPermissions());
- reader.start();
- assertTrue(readerIsInside.await(10L, TimeUnit.SECONDS));
-
- // What the scheduled updateMemberOf task does once the parent groups arrive, on its own
- // thread so that it can be made to wait for the reader rather than deadlocking with it.
- final Thread asyncLookup = new Thread(() -> {
- user.setGroups(new String[] { "direct-group", "parent-group" });
- user.setRoles(new String[0]);
- user.resetPermissions();
- });
- asyncLookup.start();
- // Give the async task time to get as far as it is able to before the reader finishes.
- Thread.sleep(200L);
-
- asyncTaskIsDone.countDown();
- reader.join(10000L);
- asyncLookup.join(10000L);
-
- final String[] permissions = user.getPermissions();
- assertTrue("parent-group missing from " + Arrays.toString(permissions),
- Arrays.stream(permissions).anyMatch(p -> p.contains("parent-group")));
- }
-
- @Test
- public void test_refresh_renewsOnceWhenConcurrentRequestsShareTheUser() throws Exception {
- // Lastaflute keeps the FessUserBean -- and therefore one EntraIdUser -- as a session
- // attribute, and FessBaseAction.godHandPrologue calls refresh() on every action request,
- // so all the requests a session has in flight arrive in the REFRESH_MARGIN window
- // together. Each of them used to see a renewed access token and run updateMemberOf, which
- // is a synchronous Microsoft Graph GET /me/memberOf on a request thread plus another
- // scheduled parent group lookup. updateMemberOf itself now runs off the request thread,
- // but scheduling it twice per rollover would still double the eventual Graph traffic --
- // exactly what the per-request guard was added to remove. scheduleUpdateMemberOf is the
- // seam refresh() now calls, so it is what proves the guard suppressed the second call.
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
- // Inside REFRESH_MARGIN, so refresh() really attempts the silent acquisition.
- final IAuthenticationResult initial = authResult(new Date(now + 30 * 1000L), "access-token");
-
- final AtomicInteger scheduleCalls = new AtomicInteger();
- final CountDownLatch winnerIsAcquiring = new CountDownLatch(1);
- final CountDownLatch loserIsDone = new CountDownLatch(1);
- ComponentUtil.register(new EntraIdAuthenticator() {
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- scheduleCalls.incrementAndGet();
- }
-
- @Override
- public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) {
- // Hold the acquisition open the way a real MSAL4J round trip does, so the second
- // request reaches refresh() while this one is still inside it.
- winnerIsAcquiring.countDown();
- try {
- loserIsDone.await(10L, TimeUnit.SECONDS);
- } catch (final InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- return authResult(new Date(now + 30 * 1000L), "renewed-access-token");
- }
- }, EntraIdAuthenticator.class.getCanonicalName());
-
- final EntraIdUser user = new EntraIdUser(initial);
- // The constructor schedules its own resolution once; only what refresh() adds is under test.
- scheduleCalls.set(0);
-
- final AtomicBoolean winnerResult = new AtomicBoolean();
- final Thread winner = new Thread(() -> winnerResult.set(user.refresh()));
- winner.start();
- assertTrue(winnerIsAcquiring.await(10L, TimeUnit.SECONDS));
-
- // The session's second concurrent request. Its token has not expired, so it must be let
- // through rather than blocked behind the acquisition, and it must not renew again. If the
- // refreshing CAS guard in refresh() were removed, this second call would reach
- // refreshTokenSilently (and, since the stub always answers "renewed", scheduleUpdateMemberOf)
- // concurrently with the winner instead of returning immediately, taking the count below to 2.
- assertTrue(user.refresh());
- loserIsDone.countDown();
- winner.join(10000L);
-
- assertTrue(winnerResult.get());
- assertEquals(1, scheduleCalls.get(), "a concurrent refresh must not schedule a second Microsoft Graph round trip");
- // Last-writer-wins used to be able to leave the older of the two results in place.
- assertEquals("renewed-access-token", user.getAuthenticationResult().accessToken());
- }
-
- @Test
- public void test_refresh_stillRenewsOnEveryRollover() throws Exception {
- // The counterpart of the test above: the guard must only suppress a *concurrent* renewal.
- // A sequential refresh has to keep re-reading the directory, otherwise a session would
- // never pick up a group change again, and the flag has to be released on the way out.
- // scheduleUpdateMemberOf is the seam refresh() now calls per rollover.
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
- final IAuthenticationResult initial = authResult(new Date(now + 30 * 1000L), "access-token");
-
- final AtomicInteger scheduleCalls = new AtomicInteger();
- final AtomicReference next = new AtomicReference<>();
- ComponentUtil.register(new EntraIdAuthenticator() {
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- scheduleCalls.incrementAndGet();
- }
-
- @Override
- public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) {
- return next.get();
- }
- }, EntraIdAuthenticator.class.getCanonicalName());
-
- final EntraIdUser user = new EntraIdUser(initial);
- scheduleCalls.set(0);
-
- next.set(authResult(new Date(now + 30 * 1000L), "second-access-token"));
- assertTrue(user.refresh());
- assertEquals(1, scheduleCalls.get(), "the first rollover must re-read the directory");
-
- next.set(authResult(new Date(now + 30 * 1000L), "third-access-token"));
- assertTrue(user.refresh());
- assertEquals(2, scheduleCalls.get(), "the guard must be released once the acquisition is over");
- assertEquals("third-access-token", user.getAuthenticationResult().accessToken());
- }
-
- @Test
- public void test_updateMemberOf_resetsThePermissionsCacheOnceGroupsResolve() throws Exception {
- // Under the new PENDING window, getPermissions() is very likely to be computed once before
- // updateMemberOf lands -- groups is still null, so only the user-scoped permission gets
- // cached. resetPermissions() inside updateMemberOf is now the only thing that clears that
- // cache once the real groups arrive; refresh() no longer calls it separately. If it
- // silently stopped firing, this stale, user-scoped-only array would pin for the rest of
- // the session.
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- // updateMemberOf calls permissionChanged() at the end, and test_app.xml does not register
- // a real activityHelper (production's app.xml does).
- ComponentUtil.register(new ActivityHelper() {
- @Override
- public void permissionChanged(final OptionalThing user) {
- // no-op
- }
- }, "activityHelper");
- ComponentUtil.register(new EntraIdAuthenticator() {
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- // keep the constructor off Graph; this test drives updateMemberOf itself
- }
- }, EntraIdAuthenticator.class.getCanonicalName());
- final EntraIdUser user = new EntraIdUser(authResult());
-
- final String[] beforePermissions = user.getPermissions();
- assertFalse("resolved-group must not be present before updateMemberOf runs: " + Arrays.toString(beforePermissions),
- Arrays.stream(beforePermissions).anyMatch(p -> p.contains("resolved-group")));
-
- final EntraIdAuthenticator resolvingAuthenticator = new EntraIdAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- groupList.add("resolved-group");
- return true;
- }
- };
- resolvingAuthenticator.updateMemberOf(user);
-
- final String[] afterPermissions = user.getPermissions();
- assertTrue("resolved-group missing from " + Arrays.toString(afterPermissions),
- Arrays.stream(afterPermissions).anyMatch(p -> p.contains("resolved-group")));
- }
-
- /**
- * Registers a SystemHelper whose clock the test drives, the way EntraIdAuthenticatorTest does.
- */
- private void registerClock(final AtomicLong clock) {
- ComponentUtil.register(new SystemHelper() {
- @Override
- public long getCurrentTimeAsLong() {
- return clock.get();
- }
- }, "systemHelper");
- }
-
- @Test
- public void test_refresh_attemptsARenewalWhenTheTokenHasExpired() {
- // FessBaseAction.godHandPrologue discards this result, so returning false without asking
- // MSAL4J for anything never ended the session: it left it holding a dead access token and
- // taking the same early exit on every later request, which is what stopped its group
- // memberships from ever being re-read again. MSAL4J's silent flow spends the cached
- // refresh token, which outlives the access token by hours, so an expired access token is
- // precisely the case worth one attempt.
- final AtomicLong clock = new AtomicLong(1_700_000_000_000L);
- registerClock(clock);
- final AtomicInteger acquisitions = new AtomicInteger();
- ComponentUtil.register(new EntraIdAuthenticator() {
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- // the constructor must not reach Microsoft Graph. Overriding the scheduling and
- // not updateMemberOf: the base implementation hands a real task to TimeoutManager,
- // so overriding only the body still leaves a timer thread racing this test.
- }
-
- @Override
- public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) {
- acquisitions.incrementAndGet();
- return null;
- }
- }, EntraIdAuthenticator.class.getCanonicalName());
-
- final EntraIdUser user = new EntraIdUser(authResult(new Date(clock.get() - 1L), "expired-access-token"));
-
- // The acquisition failed, so the token really is dead and refresh() says so.
- assertFalse(user.refresh());
- assertEquals(1, acquisitions.get(), "an expired access token must not be given up on without asking MSAL4J");
- }
-
- @Test
- public void test_refresh_recoversASessionWhoseTokenExpired() {
- // The user was idle across the expiry -- with REFRESH_MARGIN in place their last request
- // can easily have fallen before the renewal window -- and comes back. The cached refresh
- // token is still good, so the session carries on with a live token and re-read groups.
- final AtomicLong clock = new AtomicLong(1_700_000_000_000L);
- registerClock(clock);
- final AtomicInteger memberOfCalls = new AtomicInteger();
- ComponentUtil.register(new EntraIdAuthenticator() {
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- // The renewal schedules the re-resolution rather than running it: refresh() is on
- // a request thread and updateMemberOf reaches Microsoft Graph. Counting the
- // scheduling is what pins that the re-read is requested at all.
- memberOfCalls.incrementAndGet();
- }
-
- @Override
- public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) {
- return authResult(new Date(clock.get() + 60 * 60 * 1000L), "renewed-access-token");
- }
- }, EntraIdAuthenticator.class.getCanonicalName());
-
- final EntraIdUser user = new EntraIdUser(authResult(new Date(clock.get() - 1L), "expired-access-token"));
- // The constructor schedules the first resolution; only what refresh() adds is under test.
- memberOfCalls.set(0);
-
- assertTrue(user.refresh());
- assertEquals("renewed-access-token", user.getAuthenticationResult().accessToken());
- assertEquals(1, memberOfCalls.get(), "a recovered session must re-read its group memberships");
- }
-
- @Test
- public void test_refresh_holdsOffAFailingRenewalUntilTheThrottleLapses() {
- // A revoked refresh token, a disabled account, and an account a logout on another session
- // evicted from the shared MSAL4J cache all fail for good, and refresh() runs on every
- // action request. Retrying unconditionally would put back exactly the per-request round
- // trip REFRESH_MARGIN was introduced to remove.
- final AtomicLong clock = new AtomicLong(1_700_000_000_000L);
- registerClock(clock);
- final AtomicInteger acquisitions = new AtomicInteger();
- ComponentUtil.register(new EntraIdAuthenticator() {
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- // the constructor must not reach Microsoft Graph. Overriding the scheduling and
- // not updateMemberOf: the base implementation hands a real task to TimeoutManager,
- // so overriding only the body still leaves a timer thread racing this test.
- }
-
- @Override
- public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) {
- acquisitions.incrementAndGet();
- return null;
- }
- }, EntraIdAuthenticator.class.getCanonicalName());
-
- final EntraIdUser user = new EntraIdUser(authResult(new Date(clock.get() - 1L), "expired-access-token"));
-
- assertFalse(user.refresh());
- assertEquals(1, acquisitions.get(), "the first request after the expiry must attempt a renewal");
-
- // The rest of the requests this session makes inside the interval.
- assertFalse(user.refresh());
- clock.addAndGet(EntraIdUser.RENEWAL_THROTTLE_INTERVAL - 1L);
- assertFalse(user.refresh());
- assertEquals(1, acquisitions.get(), "a renewal that failed must not be retried on every request");
-
- // ... and the first one after it.
- clock.addAndGet(1L);
- assertFalse(user.refresh());
- assertEquals(2, acquisitions.get(), "the throttle must lapse rather than give up for good");
- }
-
- @Test
- public void test_refresh_doesNotStampedeWhenConcurrentRequestsFindAnExpiredToken() throws Exception {
- // The concurrency guard has to cover the expired token as well, not just the renewal
- // window: godHandPrologue calls refresh() on every action request, so the requests a
- // session has in flight when it comes back after the expiry arrive here together, and
- // each of them would otherwise run its own acquisition and its own synchronous Microsoft
- // Graph call behind updateMemberOf.
- final AtomicLong clock = new AtomicLong(1_700_000_000_000L);
- registerClock(clock);
- final AtomicInteger acquisitions = new AtomicInteger();
- final AtomicInteger memberOfCalls = new AtomicInteger();
- final CountDownLatch winnerIsAcquiring = new CountDownLatch(1);
- final CountDownLatch loserIsDone = new CountDownLatch(1);
- ComponentUtil.register(new EntraIdAuthenticator() {
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- // The scheduling is what refresh() does -- it runs on a request thread and
- // updateMemberOf reaches Microsoft Graph. Counting the base implementation's
- // TimeoutManager task instead would race this assertion.
- memberOfCalls.incrementAndGet();
- }
-
- @Override
- public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) {
- acquisitions.incrementAndGet();
- // Hold the acquisition open the way a real MSAL4J round trip does, so the second
- // request reaches refresh() while this one is still inside it.
- winnerIsAcquiring.countDown();
- try {
- loserIsDone.await(10L, TimeUnit.SECONDS);
- } catch (final InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- return authResult(new Date(clock.get() + 60 * 60 * 1000L), "renewed-access-token");
- }
- }, EntraIdAuthenticator.class.getCanonicalName());
-
- final EntraIdUser user = new EntraIdUser(authResult(new Date(clock.get() - 1L), "expired-access-token"));
- memberOfCalls.set(0);
-
- final AtomicBoolean winnerResult = new AtomicBoolean();
- final Thread winner = new Thread(() -> winnerResult.set(user.refresh()));
- winner.start();
- assertTrue(winnerIsAcquiring.await(10L, TimeUnit.SECONDS));
-
- // The session's second concurrent request. It holds nothing valid, so it reports that,
- // but it must not start a second acquisition of its own.
- assertFalse(user.refresh());
- assertEquals(1, acquisitions.get(), "a concurrent refresh must not start a second silent acquisition");
- loserIsDone.countDown();
- winner.join(10000L);
-
- assertTrue(winnerResult.get());
- assertEquals(1, memberOfCalls.get(), "a concurrent refresh must not make a second Microsoft Graph round trip");
- assertEquals("renewed-access-token", user.getAuthenticationResult().accessToken());
- }
-
- @Test
- public void test_refresh_holdsOffAfterAnExceptionToo() {
- // The exception path has to back off as well, otherwise the failure it now reports at
- // WARN -- refreshTokenSilently swallows its own, so in production this is updateMemberOf
- // or the component lookup throwing -- is written once per request rather than once per
- // interval, which is exactly the noise the throttle is there to prevent.
- final AtomicLong clock = new AtomicLong(1_700_000_000_000L);
- registerClock(clock);
- final AtomicInteger acquisitions = new AtomicInteger();
- ComponentUtil.register(new EntraIdAuthenticator() {
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- // the constructor must not reach Microsoft Graph. Overriding the scheduling and
- // not updateMemberOf: the base implementation hands a real task to TimeoutManager,
- // so overriding only the body still leaves a timer thread racing this test.
- }
-
- @Override
- public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) {
- acquisitions.incrementAndGet();
- throw new IllegalStateException("the directory could not be reached");
- }
- }, EntraIdAuthenticator.class.getCanonicalName());
-
- final EntraIdUser user = new EntraIdUser(authResult(new Date(clock.get() - 1L), "expired-access-token"));
-
- assertFalse(user.refresh());
- assertFalse(user.refresh());
- assertEquals(1, acquisitions.get(), "a renewal that threw must not be retried on every request");
-
- clock.addAndGet(EntraIdUser.RENEWAL_THROTTLE_INTERVAL);
- assertFalse(user.refresh());
- assertEquals(2, acquisitions.get(), "the throttle must lapse rather than give up for good");
- }
-}
diff --git a/src/test/java/org/codelibs/fess/app/web/base/login/FessLoginAssistTest.java b/src/test/java/org/codelibs/fess/app/web/base/login/FessLoginAssistTest.java
index cf8df9401..24be2be59 100644
--- a/src/test/java/org/codelibs/fess/app/web/base/login/FessLoginAssistTest.java
+++ b/src/test/java/org/codelibs/fess/app/web/base/login/FessLoginAssistTest.java
@@ -351,12 +351,6 @@ public void test_lazyUpgrade_upgradeDisabled_doesNotInvokeUpdateAtAll() throws E
// needsLoginSessionSyncCheck
// ---------------------------------------------------------------------
- @Test
- public void test_needsSyncCheck_samlUser_staleCheckTime_skipsCheck() {
- final FessUserBean bean = new FessUserBean(newSamlUser("saml-admin"));
- assertFalse(loginAssist.needsLoginSessionSyncCheck(bean, OptionalThing.of(STALE_CHECK_DT), NOW));
- }
-
@Test
public void test_needsSyncCheck_ldapUser_staleCheckTime_skipsCheck() {
final FessUserBean bean = new FessUserBean(new LdapUser(new Hashtable<>(), "ldap-admin"));
@@ -364,15 +358,15 @@ public void test_needsSyncCheck_ldapUser_staleCheckTime_skipsCheck() {
}
@Test
- public void test_needsSyncCheck_openIdUser_staleCheckTime_skipsCheck() {
- final FessUserBean bean = new FessUserBean(newOpenIdUser("oidc-admin"));
+ public void test_needsSyncCheck_ssoUser_staleCheckTime_skipsCheck() {
+ final FessUserBean bean = new FessUserBean(newSsoUser("sso-admin"));
assertFalse(loginAssist.needsLoginSessionSyncCheck(bean, OptionalThing.of(STALE_CHECK_DT), NOW));
}
@Test
public void test_needsSyncCheck_externalUser_neverCheckedYet_skipsCheck() {
// super returns true for "no check yet"; the external-user guard must run first.
- final FessUserBean bean = new FessUserBean(newSamlUser("saml-admin"));
+ final FessUserBean bean = new FessUserBean(newSsoUser("sso-admin"));
assertFalse(loginAssist.needsLoginSessionSyncCheck(bean, OptionalThing.empty(), NOW));
}
@@ -415,7 +409,7 @@ public void test_needsSyncCheck_nullBackedUserBean_skipsCheck() {
public void test_syncCheckLoginSession_externalUser_keepsSessionWithoutQuery() {
final ExposedLoginAssist assist = newExposedAssist();
final CountingUserBhv bhv = installCountingUserBhv(assist, null);
- final FessUserBean bean = new FessUserBean(newSamlUser("saml-admin"));
+ final FessUserBean bean = new FessUserBean(newSsoUser("sso-admin"));
bean.manageLastestSyncCheckTime(STALE_CHECK_DT);
assertTrue(assist.callSyncCheckLoginSession(bean));
@@ -511,12 +505,37 @@ public void test_container_canBuildTheLoginManager() {
/** Well within the 300-second default sync-check interval. */
private static final LocalDateTime FRESH_CHECK_DT = NOW.minusSeconds(10);
- private static SamlCredential.SamlUser newSamlUser(final String nameId) {
- return new SamlCredential.SamlUser(nameId, "session-index", null, null, null, new String[0], new String[] { "admin" });
- }
+ /**
+ * A user an SSO authenticator hands out. The guard under test asks only whether the bean holds
+ * the local {@link User} document, so what stands in for SamlUser and OpenIdUser -- which moved
+ * to the fess-sso-saml and fess-sso-oidc plugins with their authenticators -- is any other
+ * FessUser. This one is declared here rather than borrowed from another package so that the
+ * test keeps asserting the guard and not a plugin's class hierarchy.
+ */
+ private static FessUser newSsoUser(final String name) {
+ return new FessUser() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public String getName() {
+ return name;
+ }
- private static OpenIdConnectCredential.OpenIdUser newOpenIdUser(final String name) {
- return new OpenIdConnectCredential.OpenIdUser(name, new String[0], new String[] { "admin" });
+ @Override
+ public String[] getRoleNames() {
+ return new String[] { "admin" };
+ }
+
+ @Override
+ public String[] getGroupNames() {
+ return new String[0];
+ }
+
+ @Override
+ public String[] getPermissions() {
+ return new String[] { "Radmin" };
+ }
+ };
}
private static User newLocalUser(final String name) {
diff --git a/src/test/java/org/codelibs/fess/app/web/base/login/OpenIdConnectCredentialTest.java b/src/test/java/org/codelibs/fess/app/web/base/login/OpenIdConnectCredentialTest.java
deleted file mode 100644
index f5232f6e3..000000000
--- a/src/test/java/org/codelibs/fess/app/web/base/login/OpenIdConnectCredentialTest.java
+++ /dev/null
@@ -1,103 +0,0 @@
-/*
- * Copyright 2012-2025 CodeLibs Project and the Others.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
- * either express or implied. See the License for the specific language
- * governing permissions and limitations under the License.
- */
-package org.codelibs.fess.app.web.base.login;
-
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import org.codelibs.fess.unit.UnitFessTestCase;
-import org.codelibs.fess.util.ComponentUtil;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.TestInfo;
-
-/**
- * Unit tests for {@link OpenIdConnectCredential}, covering the shapes an OpenID provider can give
- * the {@code groups} claim and how {@code oic.default.groups} applies to each of them.
- */
-public class OpenIdConnectCredentialTest extends UnitFessTestCase {
-
- private static final String DEFAULT_GROUPS_KEY = "oic.default.groups";
-
- @Override
- protected void setUp(final TestInfo testInfo) throws Exception {
- super.setUp(testInfo);
- // The system properties component outlives a single test class, so the key is set on the one
- // the code actually reads and removed again below rather than swapped for a fresh instance.
- ComponentUtil.getSystemProperties().setProperty(DEFAULT_GROUPS_KEY, "fallback");
- }
-
- @Override
- protected void tearDown(final TestInfo testInfo) throws Exception {
- ComponentUtil.getSystemProperties().remove(DEFAULT_GROUPS_KEY);
- super.tearDown(testInfo);
- }
-
- private static String[] groupsOf(final Object claim) {
- final Map attributes = new HashMap<>();
- attributes.put("email", "user@example.com");
- if (claim != null) {
- attributes.put("groups", claim);
- }
- return new OpenIdConnectCredential(attributes).getUserGroups();
- }
-
- @Test
- public void test_getUserGroups_fromArrayClaim() {
- assertEquals(List.of("dev", "sales"), List.of(groupsOf(List.of("dev", "sales"))));
- }
-
- @Test
- public void test_getUserGroups_fromSingleValuedStringClaim() {
- // Some providers emit a single-valued claim as a bare string rather than a one-element array.
- // That group used to be dropped and oic.default.groups substituted for it.
- assertEquals(List.of("dev"), List.of(groupsOf("dev")));
- }
-
- @Test
- public void test_getUserGroups_fromSingleValuedStringClaim_isTrimmed() {
- assertEquals(List.of("dev"), List.of(groupsOf(" dev ")));
- }
-
- @Test
- public void test_getUserGroups_fromBlankStringClaim() {
- // The claim was sent, so the default does not apply -- the same rule as an empty array.
- assertEquals(0, groupsOf("").length);
- assertEquals(0, groupsOf(" ").length);
- }
-
- @Test
- public void test_getUserGroups_fromEmptyArrayClaim() {
- assertEquals(0, groupsOf(List.of()).length);
- }
-
- @Test
- public void test_getUserGroups_withoutClaimUsesTheDefault() {
- assertEquals(List.of("fallback"), List.of(groupsOf(null)));
- }
-
- @Test
- public void test_getUserId_fromEmailClaim() {
- final Map attributes = new HashMap<>();
- attributes.put("email", "user@example.com");
- assertEquals("user@example.com", new OpenIdConnectCredential(attributes).getUserId());
- }
-
- @Test
- public void test_getUserId_withoutEmailClaim() {
- assertNull(new OpenIdConnectCredential(new HashMap<>()).getUserId());
- }
-}
diff --git a/src/test/java/org/codelibs/fess/crawler/transformer/FessXpathTransformerTest.java b/src/test/java/org/codelibs/fess/crawler/transformer/FessXpathTransformerTest.java
index 21e9e2d81..2b6049a5d 100644
--- a/src/test/java/org/codelibs/fess/crawler/transformer/FessXpathTransformerTest.java
+++ b/src/test/java/org/codelibs/fess/crawler/transformer/FessXpathTransformerTest.java
@@ -146,7 +146,8 @@ private void setValueToObject(Object obj, String name, Object value) {
* silently, because the shadowed call neither fails nor logs. Registering {@code systemHelper}
* that way left {@code SamlAuthenticatorTest} holding the real clock instead of the fake one it
* installs, so its two tests that move the clock failed whenever this class happened to run
- * before them in the same surefire fork. What {@code ComponentUtil} holds is cleared by
+ * before them in the same surefire fork. That test has since moved to the fess-sso-saml plugin,
+ * which changes nothing about the trap: it is a property of the shared container. What {@code ComponentUtil} holds is cleared by
* {@code UnitFessTestCase#tearDown}, so nothing outlives a test method and the registration is
* simply repeated per method.
*
diff --git a/src/test/java/org/codelibs/fess/sso/SsoManagerTest.java b/src/test/java/org/codelibs/fess/sso/SsoManagerTest.java
index 3778c1fe2..7c9f73357 100644
--- a/src/test/java/org/codelibs/fess/sso/SsoManagerTest.java
+++ b/src/test/java/org/codelibs/fess/sso/SsoManagerTest.java
@@ -15,10 +15,12 @@
*/
package org.codelibs.fess.sso;
+import org.apache.logging.log4j.Level;
import org.codelibs.fess.Constants;
import org.codelibs.fess.app.web.base.login.FessLoginAssist.LoginCredentialResolver;
import org.codelibs.fess.mylasta.action.FessUserBean;
import org.codelibs.fess.mylasta.direction.FessConfig;
+import org.codelibs.fess.unit.LogCapturingAppender;
import org.codelibs.fess.unit.UnitFessTestCase;
import org.codelibs.fess.util.ComponentUtil;
import org.junit.jupiter.api.Test;
@@ -466,6 +468,133 @@ protected String getSsoType() {
assertEquals("entraiduser", ((TestLoginCredential) credential).username);
}
+ // Test the report for an sso.type no installed plugin serves
+ /**
+ * The failure this reports used to be silent: a configured sso.type whose authenticator is not
+ * registered made every caller answer null, SsoAction turned that into errors.sso_login_error
+ * and a redirect, and nothing was logged above debug. The whole symptom was a GET /sso/ that
+ * answered 302 to /login/, which no amount of reading the configuration explains -- the
+ * configuration is right and a plugin is missing. Splitting the authenticators out of core made
+ * that the ordinary state of an upgraded installation.
+ */
+ @Test
+ public void test_getAuthenticator_unservedType_warnsNamingTheComponentAndThePlugin() {
+ currentSsoType = "saml";
+ ssoManager = newManager();
+
+ final LogCapturingAppender capture = LogCapturingAppender.attach(SsoManager.class);
+ try {
+ assertNull(ssoManager.getAuthenticator(), "no samlAuthenticator is registered here");
+ assertEquals(1, capture.warnings().size());
+ final String warning = capture.warnings().get(0);
+ assertTrue(warning.contains("samlAuthenticator"), warning);
+ assertTrue(warning.contains("sso.type=saml"), warning);
+ assertTrue(warning.contains("fess-sso-saml"), warning);
+ // ERROR is a notification trigger in Fess, and an unfinished installation is not a fault.
+ assertTrue(capture.errors().isEmpty(), "must not be reported at ERROR: " + capture.errors());
+ } finally {
+ capture.detach();
+ }
+ }
+
+ /**
+ * /sso/ is anonymous and the miss is hit on every visit, so a warning per attempt is a log an
+ * unauthenticated client can fill.
+ */
+ @Test
+ public void test_getAuthenticator_unservedType_warnsOnlyOncePerType() {
+ currentSsoType = "saml";
+ ssoManager = newManager();
+
+ final LogCapturingAppender capture = LogCapturingAppender.attach(SsoManager.class);
+ try {
+ for (int i = 0; i < 5; i++) {
+ assertNull(ssoManager.getAuthenticator(), "attempt " + i);
+ }
+ assertEquals(1, capture.warnings().size());
+
+ // A different type is a different miss and is worth its own line.
+ currentSsoType = "spnego";
+ assertNull(ssoManager.getAuthenticator(), "no spnegoAuthenticator is registered here");
+ assertEquals(2, capture.warnings().size());
+ assertTrue(capture.warnings().get(1).contains("sso.type=spnego"), capture.warnings().get(1));
+ } finally {
+ capture.detach();
+ }
+ }
+
+ /**
+ * The legacy type maps to entraid before the component name is built, so the plugin the warning
+ * names is the one that actually serves it.
+ */
+ @Test
+ public void test_getAuthenticator_unservedAadType_namesTheEntraidPlugin() {
+ currentSsoType = "aad";
+ ssoManager = newManager();
+
+ final LogCapturingAppender capture = LogCapturingAppender.attach(SsoManager.class);
+ try {
+ assertNull(ssoManager.getAuthenticator(), "no entraidAuthenticator is registered here");
+ assertEquals(1, capture.warnings().size());
+ final String warning = capture.warnings().get(0);
+ assertTrue(warning.contains("entraidAuthenticator"), warning);
+ assertTrue(warning.contains("fess-sso-entraid"), warning);
+ } finally {
+ capture.detach();
+ }
+ }
+
+ /**
+ * An installation that does not use SSO must stay quiet: /sso/ is reachable whether or not it
+ * is configured, so a warning here would be a line per anonymous request on a deployment that
+ * has nothing wrong with it.
+ */
+ @Test
+ public void test_getAuthenticator_ssoNotConfigured_saysNothing() {
+ final LogCapturingAppender capture = LogCapturingAppender.attach(SsoManager.class.getName(), Level.WARN);
+ try {
+ for (final String type : new String[] { Constants.NONE, "", " ", null }) {
+ currentSsoType = type;
+ ssoManager = newManager();
+ assertNull(ssoManager.getAuthenticator(), "type=" + type);
+ }
+ assertTrue(capture.events().isEmpty(), "nothing to report for an unconfigured sso.type: " + capture.warnings());
+ } finally {
+ capture.detach();
+ }
+ }
+
+ /**
+ * Core must not ship a fess_sso++.xml. The plugins contribute their authenticators through a
+ * file of that name, and the ++ suffix merges every copy on the classpath: one in the war and
+ * one in a plugin jar define the same component twice, which makes getComponent throw
+ * TooManyRegistrationComponentException and runs the @PostConstruct that calls
+ * {@link SsoManager#register} twice. Overriding one from the other is not available either --
+ * a redefinition file is named fess_sso+<component>.xml, and a base path containing "+"
+ * is what RedefinableComponentTagHandler.redefine() refuses -- so the only workable division is
+ * for core to ship none. fess_sso.xml itself has to stay: it is what the plugins merge into.
+ */
+ @Test
+ public void test_coreShipsTheBaseFileAndNoPlusPlusFile() {
+ final ClassLoader loader = getClass().getClassLoader();
+ assertNotNull(loader.getResource("fess_sso.xml"), "fess_sso.xml is the file the plugins merge into");
+ assertNull(loader.getResource("fess_sso++.xml"),
+ "fess_sso++.xml belongs to the fess-sso-* plugins; a copy in core collides with theirs. "
+ + "On an incremental build this also fails on a stale target/classes copy left by a build "
+ + "from before the file was deleted -- which a war built from that directory really would ship. "
+ + "mvn clean test settles which one it is.");
+ }
+
+ /** An SsoManager whose sso.type is the field the tests set. */
+ private SsoManager newManager() {
+ return new SsoManager() {
+ @Override
+ protected String getSsoType() {
+ return currentSsoType;
+ }
+ };
+ }
+
// Helper classes for testing
private static class TestLoginCredential implements LoginCredential {
private final String username;
diff --git a/src/test/java/org/codelibs/fess/sso/entraid/EntraIdAuthenticatorTest.java b/src/test/java/org/codelibs/fess/sso/entraid/EntraIdAuthenticatorTest.java
deleted file mode 100644
index 20953548b..000000000
--- a/src/test/java/org/codelibs/fess/sso/entraid/EntraIdAuthenticatorTest.java
+++ /dev/null
@@ -1,3022 +0,0 @@
-/*
- * Copyright 2012-2025 CodeLibs Project and the Others.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
- * either express or implied. See the License for the specific language
- * governing permissions and limitations under the License.
- */
-package org.codelibs.fess.sso.entraid;
-
-import java.io.IOException;
-import java.io.OutputStream;
-import java.lang.reflect.Method;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
-import java.net.ServerSocket;
-import java.net.URLDecoder;
-import java.net.URLEncoder;
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Date;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.concurrent.atomic.AtomicReference;
-
-import org.codelibs.core.misc.Pair;
-import org.codelibs.curl.Curl;
-import org.codelibs.curl.CurlResponse;
-import org.codelibs.fess.app.web.base.login.ActionResponseCredential;
-import org.codelibs.fess.app.web.base.login.EntraIdCredential.EntraIdUser;
-import org.codelibs.fess.app.web.base.login.EntraIdCredential;
-import org.codelibs.fess.entity.FessUser;
-import org.codelibs.fess.exception.SsoLoginException;
-import org.codelibs.fess.exception.SsoStateException;
-import org.codelibs.fess.helper.ActivityHelper;
-import org.codelibs.fess.helper.SystemHelper;
-import org.codelibs.fess.mylasta.action.FessUserBean;
-import org.codelibs.fess.mylasta.direction.FessConfig;
-import org.codelibs.fess.unit.LogCapturingAppender;
-import org.codelibs.fess.unit.UnitFessTestCase;
-import org.codelibs.fess.util.ComponentUtil;
-import org.dbflute.optional.OptionalThing;
-import org.dbflute.utflute.mocklet.MockletHttpServletRequest;
-import org.junit.jupiter.api.Test;
-import org.lastaflute.web.login.credential.LoginCredential;
-
-import jakarta.servlet.http.Cookie;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpSession;
-
-import com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
-import com.microsoft.aad.msal4j.ConfidentialClientApplication;
-import com.microsoft.aad.msal4j.IAccount;
-import com.microsoft.aad.msal4j.IAuthenticationResult;
-import com.microsoft.aad.msal4j.ITenantProfile;
-import com.sun.net.httpserver.HttpServer;
-
-public class EntraIdAuthenticatorTest extends UnitFessTestCase {
-
- /**
- * These tests reset a setting by writing {@code ""} rather than removing it, and
- * {@code ComponentUtil.getSystemProperties()} is held by the container for the life of the
- * surefire fork. Without its own container this class would leave entraid.state.ttl,
- * entraid.default.groups, entraid.response.mode, aad.authority and aad.response.mode
- * permanently present-but-empty for every test class that runs after it -- which is the exact
- * state {@code test_getAuthority_fallsBackWhenTheLegacyKeyIsPresentButBlank} and
- * {@code test_getResponseMode_ignoresABlankLegacyKey} exist to characterise.
- *
- * @return true to create the container for each test
- */
- @Override
- protected boolean isUseOneTimeContainer() {
- return true;
- }
-
- private void setEntraIdConfig(final String clientId, final String clientSecret, final String tenant) {
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- fessConfig.setSystemProperty("entraid.client.id", clientId);
- fessConfig.setSystemProperty("entraid.client.secret", clientSecret);
- fessConfig.setSystemProperty("entraid.tenant", tenant);
- }
-
- @Test
- public void test_getClientApplication_isReusedSoItsTokenCacheSurvives() {
- // A fresh ConfidentialClientApplication starts with an empty TokenCache, and
- // acquireTokenSilently throws NO_TOKEN_IN_CACHE on a miss. Building one per call therefore
- // made silent refresh impossible; the tokens acquired at login have to stay reachable.
- try {
- setEntraIdConfig("11111111-1111-1111-1111-111111111111", "secret-1", "contoso.onmicrosoft.com");
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
-
- assertSame(authenticator.getClientApplication(), authenticator.getClientApplication());
- } finally {
- setEntraIdConfig("", "", "");
- }
- }
-
- @Test
- public void test_getClientApplication_publishesTheApplicationAndItsKeyTogether() {
- // The application and the configuration it was built from used to be two separate
- // volatile fields, read one after the other. A reader that landed between the two writes
- // paired the old application with the new key and kept returning the stale one. One
- // reference makes that unrepresentable, and the key it carries is the one the published
- // application was actually built from.
- try {
- setEntraIdConfig("11111111-1111-1111-1111-111111111111", "secret-1", "contoso.onmicrosoft.com");
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
-
- final ConfidentialClientApplication application = authenticator.getClientApplication();
-
- assertNotNull(authenticator.clientApplicationHolder);
- assertSame(application, authenticator.clientApplicationHolder.getApplication());
- assertEquals(authenticator.buildClientApplicationKey(), authenticator.clientApplicationHolder.getKey());
- } finally {
- setEntraIdConfig("", "", "");
- }
- }
-
- @Test
- public void test_getClientApplication_isRebuiltWhenTheConfigurationChanges() {
- // The client id, secret and tenant are editable from the admin screen at runtime.
- try {
- setEntraIdConfig("11111111-1111-1111-1111-111111111111", "secret-1", "contoso.onmicrosoft.com");
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final ConfidentialClientApplication first = authenticator.getClientApplication();
-
- setEntraIdConfig("11111111-1111-1111-1111-111111111111", "secret-2", "contoso.onmicrosoft.com");
- final ConfidentialClientApplication afterSecretChange = authenticator.getClientApplication();
- assertTrue("secret change must rebuild", first != afterSecretChange);
-
- setEntraIdConfig("22222222-2222-2222-2222-222222222222", "secret-2", "contoso.onmicrosoft.com");
- assertTrue("config change must rebuild", afterSecretChange != authenticator.getClientApplication());
-
- setEntraIdConfig("22222222-2222-2222-2222-222222222222", "secret-2", "fabrikam.onmicrosoft.com");
- assertTrue("config change must rebuild", afterSecretChange != authenticator.getClientApplication());
- } finally {
- setEntraIdConfig("", "", "");
- }
- }
-
- @Test
- public void test_logout_evictsTheUsersTokensFromTheSharedCache() {
- // MSAL4J's TokenCache is five unbounded LinkedHashMaps with no eviction; the only way
- // anything leaves is removeAccount(). Now that one application is shared for the whole
- // server, never calling it would keep every user who ever logged in resident until restart.
- final List removed = new ArrayList<>();
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected void removeAccount(final IAccount account) {
- removed.add(account);
- }
-
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- // keep the constructor off Microsoft Graph
- }
- };
- ComponentUtil.register(authenticator, EntraIdAuthenticator.class.getCanonicalName());
- final TestAccount account = new TestAccount();
- final EntraIdUser user = new EntraIdCredential(new TestAuthenticationResult(account)).getUser();
-
- assertNull(authenticator.logout(new FessUserBean(user)));
-
- assertEquals(1, removed.size());
- assertSame(account, removed.get(0));
- }
-
- /** An authenticator that records the evictions instead of reaching MSAL4J for them. */
- private EntraIdAuthenticator newAuthenticatorRecordingEvictions(final List evicted, final int maxCachedAccounts) {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected void removeAccount(final IAccount account) {
- synchronized (cachedAccounts) {
- cachedAccounts.remove(account.homeAccountId());
- }
- evicted.add(account);
- }
- };
- authenticator.setMaxCachedAccounts(maxCachedAccounts);
- return authenticator;
- }
-
- @Test
- public void test_trackAccount_overwritesRatherThanGrowingForTheSameAccount() {
- // MSAL4J keys its cache by home account id, so a user who logs in repeatedly replaces
- // their own tokens. The bound is distinct accounts, not logins, and this is what says so.
- final EntraIdAuthenticator authenticator = newAuthenticatorRecordingEvictions(new ArrayList<>(), 10);
- final TestAccount account = new TestAccount("account-a");
-
- for (int i = 0; i < 100; i++) {
- authenticator.trackAccount(new TestAuthenticationResult(account));
- }
-
- assertEquals(1, authenticator.cachedAccounts.size());
- }
-
- @Test
- public void test_trackAccount_evictsTheAccountThatWentLongestWithoutAToken() {
- // Nothing leaves MSAL4J's cache on its own -- no size bound, no expiry, and an expired
- // access token is filtered on read rather than removed -- so without this a server that
- // never sees a Logout keeps every account resident until it restarts.
- final List evicted = new ArrayList<>();
- final EntraIdAuthenticator authenticator = newAuthenticatorRecordingEvictions(evicted, 2);
-
- authenticator.trackAccount(new TestAuthenticationResult(new TestAccount("account-a")));
- authenticator.trackAccount(new TestAuthenticationResult(new TestAccount("account-b")));
- authenticator.trackAccount(new TestAuthenticationResult(new TestAccount("account-c")));
-
- assertEquals(1, evicted.size());
- assertEquals("account-a", evicted.get(0).homeAccountId());
- assertEquals(Set.of("account-b", "account-c"), authenticator.cachedAccounts.keySet());
- }
-
- @Test
- public void test_trackAccount_keepsTheAccountThatIsStillAcquiring() {
- // Access order, not insertion order. A session that has been alive for days keeps
- // acquiring, and evicting it first -- which is what insertion order would do -- would
- // throw away exactly the account most likely to still be in use.
- final List evicted = new ArrayList<>();
- final EntraIdAuthenticator authenticator = newAuthenticatorRecordingEvictions(evicted, 2);
-
- final TestAccount oldest = new TestAccount("account-a");
- authenticator.trackAccount(new TestAuthenticationResult(oldest));
- authenticator.trackAccount(new TestAuthenticationResult(new TestAccount("account-b")));
- authenticator.trackAccount(new TestAuthenticationResult(oldest));
- authenticator.trackAccount(new TestAuthenticationResult(new TestAccount("account-c")));
-
- assertEquals(1, evicted.size());
- assertEquals("account-b", evicted.get(0).homeAccountId());
- assertEquals(Set.of("account-a", "account-c"), authenticator.cachedAccounts.keySet());
- }
-
- @Test
- public void test_trackAccount_ignoresAnAcquisitionWithNothingToTrack() {
- final EntraIdAuthenticator authenticator = newAuthenticatorRecordingEvictions(new ArrayList<>(), 2);
-
- authenticator.trackAccount(null);
- authenticator.trackAccount(new TestAuthenticationResult(null));
- authenticator.trackAccount(new TestAuthenticationResult(new TestAccount("")));
-
- assertTrue(authenticator.cachedAccounts.isEmpty());
- }
-
- @Test
- public void test_removeAccount_freesTheSlotSoALogoutIsNotJustAnMsalCall() {
- // A user who logs out must stop counting against the bound, otherwise a server with a
- // steady turnover evicts live sessions to make room for accounts that already left.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- authenticator.setMaxCachedAccounts(2);
- final TestAccount account = new TestAccount("account-a");
- authenticator.trackAccount(new TestAuthenticationResult(account));
- assertEquals(1, authenticator.cachedAccounts.size());
-
- // Entra ID is unconfigured here, so the MSAL4J call behind this fails and is swallowed.
- // The slot has to be freed regardless of whether that call got anywhere.
- setEntraIdConfig("", "", "");
- authenticator.removeAccount(account);
-
- assertTrue(authenticator.cachedAccounts.isEmpty());
- }
-
- @Test
- public void test_logout_ignoresAUserThatIsNotAnEntraIdUser() {
- // SPNEGO, SAML and LDAP users reach the same logout hook.
- final List removed = new ArrayList<>();
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected void removeAccount(final IAccount account) {
- removed.add(account);
- }
- };
-
- assertNull(authenticator.logout(new FessUserBean(new TestFessUser())));
- assertTrue(removed.isEmpty());
- }
-
- /** A FessUser that is not an EntraIdUser, standing in for the other authenticators. */
- private static class TestFessUser implements org.codelibs.fess.entity.FessUser {
- private static final long serialVersionUID = 1L;
-
- @Override
- public String getName() {
- return "not-an-entraid-user";
- }
-
- @Override
- public String[] getRoleNames() {
- return new String[0];
- }
-
- @Override
- public String[] getGroupNames() {
- return new String[0];
- }
-
- @Override
- public String[] getPermissions() {
- return new String[0];
- }
- }
-
- private static class TestAccount implements IAccount {
- private static final long serialVersionUID = 1L;
- private final String homeAccountId;
-
- TestAccount() {
- this("home-account-id");
- }
-
- TestAccount(final String homeAccountId) {
- this.homeAccountId = homeAccountId;
- }
-
- @Override
- public String homeAccountId() {
- return homeAccountId;
- }
-
- @Override
- public String environment() {
- return "login.microsoftonline.com";
- }
-
- @Override
- public String username() {
- return "taro@contoso.onmicrosoft.com";
- }
-
- @Override
- public Map getTenantProfiles() {
- return Collections.emptyMap();
- }
- }
-
- private static class TestAuthenticationResult implements IAuthenticationResult {
- private static final long serialVersionUID = 1L;
- private final IAccount account;
- private final Date expiresOn;
- private final String accessToken;
- private final String idToken;
-
- TestAuthenticationResult(final IAccount account) {
- this(account, new Date(Long.MAX_VALUE));
- }
-
- TestAuthenticationResult(final IAccount account, final Date expiresOn) {
- this(account, expiresOn, "access-token", "id-token");
- }
-
- TestAuthenticationResult(final IAccount account, final Date expiresOn, final String accessToken) {
- this(account, expiresOn, accessToken, "id-token");
- }
-
- TestAuthenticationResult(final IAccount account, final String idToken) {
- this(account, new Date(Long.MAX_VALUE), "access-token", idToken);
- }
-
- TestAuthenticationResult(final IAccount account, final Date expiresOn, final String accessToken, final String idToken) {
- this.account = account;
- this.expiresOn = expiresOn;
- this.accessToken = accessToken;
- this.idToken = idToken;
- }
-
- @Override
- public String accessToken() {
- return accessToken;
- }
-
- @Override
- public String idToken() {
- return idToken;
- }
-
- @Override
- public IAccount account() {
- return account;
- }
-
- @Override
- public ITenantProfile tenantProfile() {
- return null;
- }
-
- @Override
- public String environment() {
- return "login.microsoftonline.com";
- }
-
- @Override
- public String scopes() {
- return "https://graph.microsoft.com/.default";
- }
-
- @Override
- public Date expiresOnDate() {
- return expiresOn;
- }
- }
-
- @Test
- public void test_getClientApplication_isBuiltOnceUnderConcurrentAccess() throws Exception {
- try {
- setEntraIdConfig("11111111-1111-1111-1111-111111111111", "secret-1", "contoso.onmicrosoft.com");
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final int threads = 8;
- final CountDownLatch start = new CountDownLatch(1);
- final List seen = Collections.synchronizedList(new ArrayList<>());
- final List workers = new ArrayList<>();
- for (int i = 0; i < threads; i++) {
- final Thread t = new Thread(() -> {
- try {
- start.await();
- } catch (final InterruptedException e) {
- Thread.currentThread().interrupt();
- return;
- }
- seen.add(authenticator.getClientApplication());
- });
- workers.add(t);
- t.start();
- }
- start.countDown();
- for (final Thread t : workers) {
- t.join(10000L);
- }
-
- assertEquals(threads, seen.size());
- // Two instances means two token caches, and a login cached in one is invisible to the
- // other when its refresh comes round.
- seen.forEach(app -> assertSame(seen.get(0), app));
- } finally {
- setEntraIdConfig("", "", "");
- }
- }
-
- /** Lets a test move the clock that state expiry is measured against. */
- private final AtomicLong clock = new AtomicLong(1_000_000L);
-
- private EntraIdAuthenticator newAuthenticatorWithControlledClock() {
- ComponentUtil.register(new SystemHelper() {
- @Override
- public long getCurrentTimeAsLong() {
- return clock.get();
- }
- }, "systemHelper");
- return new EntraIdAuthenticator();
- }
-
- @Test
- public void test_maskSecret() {
- // Long values keep a short prefix so two different codes can still be told apart
- // in a log, without the value itself being usable.
- assertEquals("abcdefgh***", EntraIdAuthenticator.maskSecret("abcdefghijklmnopqrstuvwxyz"));
- // Values shorter than the prefix are not padded out.
- assertEquals("abc***", EntraIdAuthenticator.maskSecret("abc"));
- assertEquals("abcdefgh***", EntraIdAuthenticator.maskSecret("abcdefgh"));
- // Null and empty must not blow up: these are logged on paths where the identity
- // provider may simply not have sent the field.
- assertNull(EntraIdAuthenticator.maskSecret(null));
- assertEquals("", EntraIdAuthenticator.maskSecret(""));
- }
-
- @Test
- public void test_maskParams_masksCredentialsAndKeepsDiagnostics() {
- final Map> params = new LinkedHashMap<>();
- params.put("code", List.of("0.AXkAauthorizationcodevalue"));
- params.put("id_token", List.of("eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.payload.sig"));
- params.put("state", List.of("2b1f5c3e-0000-0000-0000-000000000000"));
- params.put("error", List.of("access_denied"));
- params.put("error_description", List.of("AADSTS65004: User declined to consent."));
-
- final Map> masked = EntraIdAuthenticator.maskParams(params);
-
- // Credentials are truncated.
- assertEquals(List.of("0.AXkAau***"), masked.get("code"));
- assertEquals(List.of("eyJ0eXAi***"), masked.get("id_token"));
- // Everything needed to diagnose a failed login is kept verbatim.
- assertEquals(List.of("2b1f5c3e-0000-0000-0000-000000000000"), masked.get("state"));
- assertEquals(List.of("access_denied"), masked.get("error"));
- assertEquals(List.of("AADSTS65004: User declined to consent."), masked.get("error_description"));
- // The key set is unchanged, so the log still shows which artifacts arrived.
- assertEquals(params.keySet(), masked.keySet());
- // The caller's map is not modified.
- assertEquals(List.of("0.AXkAauthorizationcodevalue"), params.get("code"));
- }
-
- @Test
- public void test_maskParams_isCaseInsensitiveOnKeys() {
- final Map> params = new LinkedHashMap<>();
- params.put("Code", List.of("0.AXkAauthorizationcodevalue"));
- params.put("ACCESS_TOKEN", List.of("accesstokenvalue12345"));
-
- final Map> masked = EntraIdAuthenticator.maskParams(params);
-
- assertEquals(List.of("0.AXkAau***"), masked.get("Code"));
- assertEquals(List.of("accessto***"), masked.get("ACCESS_TOKEN"));
- }
-
- @Test
- public void test_getAuthUrl_requestsQueryResponseMode() {
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
-
- final String authUrl = authenticator.getAuthUrl(getMockRequest());
-
- // form_post makes Entra ID reply with a cross-site POST, which does not carry a
- // SameSite=Lax session cookie -- and Fess sets SameSite=Lax on JSESSIONID by default
- // (tomcat.sameSiteCookies). Without the session there is no stored state, so the
- // callback can never be validated. query mode replies with a top-level GET instead.
- assertTrue(authUrl.contains("response_mode=query"));
- assertFalse(authUrl.contains("response_mode=form_post"));
- // The rest of the authorization request is unchanged.
- assertTrue(authUrl.contains("response_type=code"));
- assertTrue(authUrl.contains("&state="));
- assertTrue(authUrl.contains("&nonce="));
- }
-
- @Test
- public void test_getAuthUrl_alwaysUsesTheV2Endpoint() {
- // msal4j hardcodes oauth2/v2.0/token as the token endpoint of an AAD authority, so a code
- // minted at the v1.0 /oauth2/authorize could never be redeemed. The setter is kept so an
- // out-of-tree fess_sso+entraidAuthenticator.xml still loads, but it no longer selects a
- // login that always fails.
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- authenticator.setUseV2Endpoint(false);
-
- final String authUrl = authenticator.getAuthUrl(getMockRequest());
-
- assertTrue(authUrl.contains("/oauth2/v2.0/authorize?"));
- assertFalse(authUrl.contains("resource=https%3a%2f%2fgraph.microsoft.com"));
- assertTrue(authUrl.contains("response_mode=query"));
- }
-
- @Test
- public void test_getAuthUrl_requestsTheOidcScopesUpFront() {
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
-
- final String authUrl = authenticator.getAuthUrl(getMockRequest());
-
- // msal4j already prepends these to the token request (OAuthAuthorizationGrant's
- // COMMON_SCOPES), so asking for them at the authorization endpoint too keeps consent and
- // the token exchange asking for the same thing.
- final String scope = URLDecoder.decode(authUrl.replaceFirst("(?s).*[?&]scope=([^&]*).*", "$1"), StandardCharsets.UTF_8);
- assertEquals("openid profile offline_access https://graph.microsoft.com/.default", scope);
- }
-
- @Test
- public void test_getAuthUrl_encodesTheScopeParameter() {
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
-
- final String authUrl = authenticator.getAuthUrl(getMockRequest());
-
- // A multi-valued scope is space separated, which cannot be sent raw.
- assertFalse(authUrl.contains("scope=openid profile"));
- assertTrue(authUrl.contains("&client_id="));
- }
-
- @Test
- public void test_containsAuthenticationData_acceptsQueryModeCallback() {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final MockletHttpServletRequest request = getMockRequest();
- request.setMethod("GET");
- request.setParameter("code", "0.AXkAauthorizationcodevalue");
- request.setParameter("state", "2b1f5c3e-0000-0000-0000-000000000000");
-
- assertTrue(authenticator.containsAuthenticationData(request));
- }
-
- @Test
- public void test_containsAuthenticationData_acceptsFormPostCallback() {
- // An existing deployment that already set tomcat.sameSiteCookies=none keeps working.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final MockletHttpServletRequest request = getMockRequest();
- request.setMethod("POST");
- request.setParameter("code", "0.AXkAauthorizationcodevalue");
-
- assertTrue(authenticator.containsAuthenticationData(request));
- }
-
- @Test
- public void test_containsAuthenticationData_acceptsErrorCallback() {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final MockletHttpServletRequest request = getMockRequest();
- request.setMethod("GET");
- request.setParameter("error", "access_denied");
-
- assertTrue(authenticator.containsAuthenticationData(request));
- }
-
- @Test
- public void test_getLoginCredential_surfacesUnexpectedFailures() {
- // A swallowed failure leaves the operator with nothing above DEBUG to work from.
- // SsoAction logs SsoLoginException at WARN and shows the SSO error message, which is
- // what the OpenID Connect authenticator already relies on.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected LoginCredential processAuthenticationData(final HttpServletRequest request) {
- throw new IllegalStateException("graph is down");
- }
- };
- final MockletHttpServletRequest request = getMockRequest();
- request.setMethod("GET");
- request.setParameter("code", "0.AXkAauthorizationcodevalue");
- request.getSession();
-
- try {
- authenticator.getLoginCredential();
- fail("expected SsoLoginException");
- } catch (final SsoLoginException e) {
- assertEquals("graph is down", e.getCause().getMessage());
- }
- }
-
- @Test
- public void test_getLoginCredential_propagatesSsoLoginExceptionUnwrapped() {
- final SsoLoginException thrown = new SsoLoginException("could not validate state");
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected LoginCredential processAuthenticationData(final HttpServletRequest request) {
- throw thrown;
- }
- };
- final MockletHttpServletRequest request = getMockRequest();
- request.setMethod("GET");
- request.setParameter("code", "0.AXkAauthorizationcodevalue");
- request.getSession();
-
- try {
- authenticator.getLoginCredential();
- fail("expected SsoLoginException");
- } catch (final SsoLoginException e) {
- assertSame(thrown, e);
- }
- }
-
- @Test
- public void test_getLoginCredential_doesNotRedirectACallbackThatLostItsSession() {
- // Redirecting a callback that arrived without a session sends the user straight back
- // here without a session again, which is the infinite loop this change is about.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final MockletHttpServletRequest request = getMockRequest();
- request.setMethod("GET");
- request.setParameter("code", "0.AXkAauthorizationcodevalue");
- request.setParameter("state", "2b1f5c3e-0000-0000-0000-000000000000");
- assertNull(request.getSession(false));
-
- assertNull(authenticator.getLoginCredential());
- }
-
- @Test
- public void test_getLoginCredential_restartsTheLoginWhenTheSessionMerelyExpired() {
- // The browser did send a session id, so it stores and returns cookies; the container just
- // no longer knows that session. 15.7 recovered by bouncing back to Entra ID, and dropping
- // the user on the local login form instead leaves them stuck -- that form has no SSO link.
- // This cannot loop: getAuthUrl creates a session, so the next callback either finds it or
- // arrives with no session id at all, which is the branch the sibling test pins.
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final MockletHttpServletRequest request = getMockRequest();
- request.setMethod("GET");
- request.setParameter("code", "0.AXkAauthorizationcodevalue");
- request.setParameter("state", "2b1f5c3e-0000-0000-0000-000000000000");
- request.addCookie(new Cookie("jsessionid", "AB1C2D3E4F5061728394A5B6C7D8E9F0"));
- assertNull(request.getSession(false));
- assertNotNull(request.getRequestedSessionId());
- assertFalse(request.isRequestedSessionIdValid());
-
- try {
- setEntraIdConfig("11111111-1111-1111-1111-111111111111", "secret-1", "contoso.onmicrosoft.com");
-
- final LoginCredential credential = authenticator.getLoginCredential();
-
- assertTrue(credential instanceof ActionResponseCredential);
- } finally {
- setEntraIdConfig("", "", "");
- }
- }
-
- @Test
- public void test_getLoginCredential_reportsAnUnconfiguredTenantInsteadOfRedirecting() {
- // Unconfigured, Fess used to redirect to
- // https://login.microsoftonline.com//oauth2/v2.0/authorize?...&client_id= and log nothing,
- // so the only symptom was a Microsoft error page.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final MockletHttpServletRequest request = getMockRequest();
- request.setMethod("GET");
- setEntraIdConfig("", "", "");
-
- try {
- authenticator.getLoginCredential();
- fail("expected SsoLoginException");
- } catch (final SsoLoginException e) {
- assertTrue(e.getMessage(), e.getMessage().contains("entraid.tenant"));
- assertTrue(e.getMessage(), e.getMessage().contains("entraid.client.id"));
- assertTrue(e.getMessage(), e.getMessage().contains("entraid.client.secret"));
- }
- }
-
- @Test
- public void test_getLoginCredential_reportsThePartiallyConfiguredKeysOnly() {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final MockletHttpServletRequest request = getMockRequest();
- request.setMethod("GET");
-
- try {
- setEntraIdConfig("11111111-1111-1111-1111-111111111111", "secret-1", "");
-
- authenticator.getLoginCredential();
- fail("expected SsoLoginException");
- } catch (final SsoLoginException e) {
- assertTrue(e.getMessage(), e.getMessage().contains("entraid.tenant"));
- assertFalse(e.getMessage(), e.getMessage().contains("entraid.client.id"));
- } finally {
- setEntraIdConfig("", "", "");
- }
- }
-
- @Test
- public void test_getLoginCredential_thrownEagerlyRatherThanFromTheRedirectSupplier() {
- // SsoAction runs the ActionResponseCredential supplier outside the block that catches
- // SsoLoginException, so a throw from inside the lambda reaches the generic error page
- // instead of the SSO error message. The check therefore has to happen before the
- // credential is built, not when it is executed.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final MockletHttpServletRequest request = getMockRequest();
- request.setMethod("GET");
- setEntraIdConfig("", "", "");
-
- try {
- authenticator.getLoginCredential();
- fail("expected SsoLoginException before any credential was returned");
- } catch (final SsoLoginException e) {
- // expected
- }
- }
-
- @Test
- public void test_getAuthUrl_issuesAnUnguessableState() {
- // org.codelibs.core.net.UuidUtil is hex(localIP) + hex(identityHashCode(RANDOM)) +
- // hex((int) (currentTimeMillis() >> 32)) + hex(SecureRandom.nextInt()): the first 16 hex
- // characters never change within a JVM and the timestamp word moves every ~49.7 days, so
- // under 32 bits actually vary per call. RFC 6749 section 10.12 wants the state
- // unguessable.
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final Set states = new HashSet<>();
- final Set prefixes = new HashSet<>();
- for (int i = 0; i < 200; i++) {
- final String state = URLDecoder.decode(
- authenticator.getAuthUrl(newAuthUrlRequest()).replaceFirst("(?s).*[&?]state=([^&]*).*", "$1"), StandardCharsets.UTF_8);
- states.add(state);
- prefixes.add(state.replace("-", "").substring(0, 16));
- }
-
- assertEquals(200, states.size());
- // The whole point: a fixed leading half is what UuidUtil produced.
- assertTrue("distinct prefixes: " + prefixes.size(), prefixes.size() > 190);
- }
-
- @Test
- public void test_getAuthUrl_issuesADistinctNoncePerRequest() {
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final Set nonces = new HashSet<>();
- for (int i = 0; i < 50; i++) {
- nonces.add(authenticator.getAuthUrl(newAuthUrlRequest()).replaceFirst("(?s).*&nonce=([^&]*).*", "$1"));
- }
-
- assertEquals(50, nonces.size());
- }
-
- @Test
- public void test_createGroupCache_isBounded() {
- // Every sibling cache in Fess caps its size; this one only had an expiry, so a tenant with
- // many groups grew it without bound until the TTL came round.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- assertTrue(authenticator.maxGroupCacheSize > 0);
- authenticator.setMaxGroupCacheSize(2);
-
- final Cache> cache = authenticator.createGroupCache();
- for (int i = 0; i < 10; i++) {
- cache.put("group-" + i, new Pair<>(new String[0], new String[0]));
- }
- cache.cleanUp();
-
- assertTrue("size=" + cache.size(), cache.size() <= 2);
- }
-
- @Test
- public void test_containsAuthenticationData_ignoresRequestWithoutArtifacts() {
- // A plain visit to /sso must still start a fresh login instead of being treated
- // as a callback.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final MockletHttpServletRequest request = getMockRequest();
- request.setMethod("GET");
-
- assertFalse(authenticator.containsAuthenticationData(request));
- }
-
- @Test
- public void test_graphTimeouts_haveBoundedDefaults() {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- // curl4j leaves both timeouts at -1 unless told otherwise, and -1 means "never give up".
- // Any non-positive default would put an unbounded Graph call back on the login path.
- assertTrue(authenticator.graphConnectTimeout > 0);
- assertTrue(authenticator.graphReadTimeout > 0);
- }
-
- @Test
- public void test_createGraphRequest_stopsWaitingOnAnUnresponsiveEndpoint() throws Exception {
- // A server that accepts the connection and then never answers. Without a read timeout
- // this call never returns, and on the login path that blocks the request thread.
- try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- authenticator.setGraphReadTimeout(300);
- final String url = "http://" + server.getInetAddress().getHostAddress() + ":" + server.getLocalPort() + "/v1.0/me/memberOf";
-
- final long start = System.currentTimeMillis();
- try (CurlResponse response = authenticator.createGraphRequest(Curl.get(url), "access-token").execute()) {
- fail("expected the request to time out");
- } catch (final Exception expected) {
- // curl4j wraps the SocketTimeoutException
- }
- final long elapsed = System.currentTimeMillis() - start;
- // Well under the 30s default, so an ignored setter fails here instead of just being slow.
- assertTrue("took " + elapsed + "ms", elapsed < 5000L);
- }
- }
-
- @Test
- public void test_storeStateInSession_dropsExpiredStatesOnWrite() {
- // Expired states used to be cleared only when a callback arrived. A user who keeps
- // starting logins without finishing one grew the session map without bound.
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final HttpSession session = getMockRequest().getSession();
-
- authenticator.storeStateInSession(session, "state-1", "nonce-1");
- assertEquals(1, authenticator.getStateMap(session).size());
-
- // getStateTtl() defaults to 3600 and is compared in seconds.
- clock.addAndGet(3601L * 1000L);
- authenticator.storeStateInSession(session, "state-2", "nonce-2");
-
- final Map stateMap = authenticator.getStateMap(session);
- assertEquals(1, stateMap.size());
- assertTrue(stateMap.containsKey("state-2"));
- }
-
- @Test
- public void test_storeStateInSession_capsTheNumberOfLiveStates() {
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- authenticator.setMaxStates(3);
- final HttpSession session = getMockRequest().getSession();
-
- for (int i = 0; i < 20; i++) {
- clock.addAndGet(1000L);
- authenticator.storeStateInSession(session, "state-" + i, "nonce-" + i);
- }
-
- final Map stateMap = authenticator.getStateMap(session);
- assertEquals(3, stateMap.size());
- // The most recent attempts are the ones a user can still complete.
- assertTrue(stateMap.containsKey("state-19"));
- assertTrue(stateMap.containsKey("state-18"));
- assertTrue(stateMap.containsKey("state-17"));
- }
-
- @Test
- public void test_getStateMap_migratesALegacyHashMap() {
- // A session created before this change holds a plain HashMap under the same key.
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final HttpSession session = getMockRequest().getSession();
- final Map legacy = new HashMap<>();
- legacy.put("legacy-state", new EntraIdAuthenticator.StateData("legacy-nonce", clock.get()));
- session.setAttribute("entraidStates", legacy);
-
- final Map stateMap = authenticator.getStateMap(session);
-
- assertTrue(stateMap instanceof ConcurrentHashMap);
- assertEquals("legacy-nonce", stateMap.get("legacy-state").getNonce());
- // The migrated map is the one stored back on the session, so later writes are not lost.
- assertSame(stateMap, session.getAttribute("entraidStates"));
- }
-
- @Test
- public void test_getStateMap_isCreatedOnceUnderConcurrentAccess() throws Exception {
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final HttpSession session = getMockRequest().getSession();
- final int threads = 8;
- final CountDownLatch start = new CountDownLatch(1);
- final List> seen = Collections.synchronizedList(new ArrayList<>());
- final List workers = new ArrayList<>();
- for (int i = 0; i < threads; i++) {
- final Thread t = new Thread(() -> {
- try {
- start.await();
- } catch (final InterruptedException e) {
- Thread.currentThread().interrupt();
- return;
- }
- seen.add(authenticator.getStateMap(session));
- });
- workers.add(t);
- t.start();
- }
- start.countDown();
- for (final Thread t : workers) {
- t.join(10000L);
- }
-
- assertEquals(threads, seen.size());
- // Every caller must share one map, otherwise a state stored by one request is invisible
- // to the request that has to validate it.
- seen.forEach(m -> assertSame(seen.get(0), m));
- }
-
- @Test
- public void test_removeStateFromSession_stillDropsExpiredStates() {
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final HttpSession session = getMockRequest().getSession();
- authenticator.storeStateInSession(session, "state-1", "nonce-1");
-
- clock.addAndGet(3601L * 1000L);
-
- assertNull(authenticator.removeStateFromSession(session, "state-1"));
- assertEquals(0, authenticator.getStateMap(session).size());
- }
-
- @Test
- public void test_removeStateFromSession_returnsAndConsumesALiveState() {
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final HttpSession session = getMockRequest().getSession();
- authenticator.storeStateInSession(session, "state-1", "nonce-1");
-
- final EntraIdAuthenticator.StateData stateData = authenticator.removeStateFromSession(session, "state-1");
-
- assertNotNull(stateData);
- assertEquals("nonce-1", stateData.getNonce());
- // A state is single use.
- assertNull(authenticator.removeStateFromSession(session, "state-1"));
- }
-
- /**
- * An authenticator whose Graph access is replaced by a scripted one, so the caching and
- * recursion around it can be exercised without a tenant.
- */
- private static class ScriptedAuthenticator extends EntraIdAuthenticator {
- private final Map parents = new HashMap<>();
- private final List lookups = new ArrayList<>();
- private final Set failing = new HashSet<>();
-
- @Override
- protected String[] getMemberGroupIds(final EntraIdUser user, final String id) throws IOException {
- lookups.add(id);
- if (failing.contains(id)) {
- throw new IOException("simulated Graph failure for " + id);
- }
- return parents.getOrDefault(id, new String[0]);
- }
-
- @Override
- protected boolean processGroup(final EntraIdUser user, final List groupList, final List roleList, final String id) {
- groupList.add(id);
- return true;
- }
- }
-
- private ScriptedAuthenticator newScriptedAuthenticator() {
- final ScriptedAuthenticator authenticator = new ScriptedAuthenticator();
- authenticator.groupCache = CacheBuilder.newBuilder().build();
- return authenticator;
- }
-
- @Test
- public void test_getParentGroup_cachesASuccessfulLookup() {
- final ScriptedAuthenticator authenticator = newScriptedAuthenticator();
- authenticator.parents.put("group-a", new String[] { "group-b" });
-
- final Pair first = authenticator.getParentGroup(null, "group-a", 0);
- final Pair second = authenticator.getParentGroup(null, "group-a", 0);
-
- assertEquals(1, first.getFirst().length);
- assertEquals("group-b", first.getFirst()[0]);
- assertEquals(1, second.getFirst().length);
- // "group-a" is looked up once; "group-b" is walked once while loading it.
- assertEquals(1, authenticator.lookups.stream().filter("group-a"::equals).count());
- }
-
- @Test
- public void test_getParentGroup_doesNotServeAnEntryBuiltFromAnotherPermissionFieldSetting() {
- // The cached pair holds the permission values entraid.permission.fields selected, not the
- // raw Graph answer. Keyed by group id alone, a change to the setting was ignored for
- // nested groups until the entry expired -- while direct memberships, which are read from
- // Graph on every login, picked it up at once. Narrowing the setting is the case that
- // matters: displayName is neither domain-qualified nor unique, and removing it once it has
- // matched the wrong documents must not keep granting it for another ten minutes.
- final ScriptedAuthenticator authenticator = newScriptedAuthenticator();
- authenticator.parents.put("group-a", new String[] { "group-b" });
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- try {
- fessConfig.setSystemProperty("entraid.permission.fields", "mail");
- authenticator.getParentGroup(null, "group-a", 0);
- assertEquals(1, authenticator.lookups.stream().filter("group-a"::equals).count());
-
- fessConfig.setSystemProperty("entraid.permission.fields", "mail,displayName");
- authenticator.getParentGroup(null, "group-a", 0);
-
- // Read again rather than served from the entry the old setting produced.
- assertEquals(2, authenticator.lookups.stream().filter("group-a"::equals).count());
- // The entry the first setting produced is still addressable under its own key, so the
- // two settings do not evict each other.
- fessConfig.setSystemProperty("entraid.permission.fields", "mail");
- assertNotNull(authenticator.groupCache.getIfPresent(authenticator.buildGroupCacheKey("group-a")));
- } finally {
- fessConfig.setSystemProperty("entraid.permission.fields", "");
- }
- }
-
- @Test
- public void test_getParentGroup_doesNotCacheAFailedLookup() {
- // A throttled or briefly unreachable Graph used to leave an empty result in the cache,
- // so the user silently lost their parent-group permissions for the whole cache TTL.
- final ScriptedAuthenticator authenticator = newScriptedAuthenticator();
- authenticator.failing.add("group-a");
-
- final Pair failed = authenticator.getParentGroup(null, "group-a", 0);
- assertEquals(0, failed.getFirst().length);
- assertNull(authenticator.groupCache.getIfPresent(authenticator.buildGroupCacheKey("group-a")));
-
- authenticator.failing.remove("group-a");
- authenticator.parents.put("group-a", new String[] { "group-b" });
-
- final Pair recovered = authenticator.getParentGroup(null, "group-a", 0);
- assertEquals(1, recovered.getFirst().length);
- assertEquals("group-b", recovered.getFirst()[0]);
- }
-
- @Test
- public void test_getParentGroup_cachesAGenuineEmptyResult() {
- // A group with no parents is a real answer, not a transient failure, so it is cached.
- // getMemberGroupIds maps Graph's Request_ResourceNotFound onto this same empty result.
- final ScriptedAuthenticator authenticator = newScriptedAuthenticator();
-
- final Pair result = authenticator.getParentGroup(null, "group-a", 0);
-
- assertEquals(0, result.getFirst().length);
- assertNotNull(authenticator.groupCache.getIfPresent(authenticator.buildGroupCacheKey("group-a")));
- }
-
- @Test
- public void test_getParentGroup_doesNotRecurseOnceTheParentWasResolved() {
- // Characterisation: processGroup() unconditionally adds the id it was asked about, so the
- // `!groupList.contains(value)` guard in loadParentGroup is false on every success path and
- // the nested-group recursion never runs. maxGroupDepth therefore has no effect here. This
- // is consistent with Graph's getMemberGroups already being transitive, but it means the
- // recursion is not what resolves nested groups -- pinning it so a change is deliberate.
- final ScriptedAuthenticator authenticator = newScriptedAuthenticator();
- authenticator.parents.put("group-a", new String[] { "group-b" });
- authenticator.parents.put("group-b", new String[] { "group-c" });
-
- final Pair result = authenticator.getParentGroup(null, "group-a", 0);
-
- assertEquals(1, result.getFirst().length);
- assertEquals("group-b", result.getFirst()[0]);
- assertFalse(authenticator.lookups.contains("group-b"));
- }
-
- @Test
- public void test_getParentGroup_survivesAnUncheckedFailureFromTheLoader() {
- // Guava wraps an unchecked exception from the loader in UncheckedExecutionException,
- // which is not an ExecutionException, so it used to escape the catch entirely. The Graph
- // JSON parser throws CurlException, a RuntimeException, on a non-JSON error body.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected String[] getMemberGroupIds(final EntraIdUser user, final String id) {
- throw new IllegalStateException("non-JSON error body");
- }
- };
- authenticator.groupCache = CacheBuilder.newBuilder().build();
-
- final Pair result = authenticator.getParentGroup(null, "group-a", 0);
-
- assertEquals(0, result.getFirst().length);
- assertEquals(0, result.getSecond().length);
- assertNull(authenticator.groupCache.getIfPresent(authenticator.buildGroupCacheKey("group-a")));
- }
-
- @Test
- public void test_applyGraphThrottle_honoursTheRetryAfterGraphSent() {
- // curl4j does not throw on a non-2xx response -- CurlRequest hands back the error stream --
- // so a Graph 429 arrives as an ordinary parsed body and the status code is the only place
- // the throttling is visible.
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final CurlResponse response = new CurlResponse();
- response.setHttpStatusCode(429);
- response.setHeaders(Map.of("Retry-After", List.of("120")));
-
- authenticator.applyGraphThrottle(response);
-
- assertEquals(clock.get() + 120_000L, authenticator.graphThrottledUntil);
- assertTrue(authenticator.isGraphThrottled());
-
- clock.addAndGet(120_000L);
- assertFalse("the backoff has to lapse on its own", authenticator.isGraphThrottled());
- }
-
- @Test
- public void test_applyGraphThrottle_alsoBacksOffOnServiceUnavailable() {
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final CurlResponse response = new CurlResponse();
- response.setHttpStatusCode(503);
-
- authenticator.applyGraphThrottle(response);
-
- // No Retry-After: the default backoff applies rather than none at all.
- assertEquals(clock.get() + 60_000L, authenticator.graphThrottledUntil);
- }
-
- @Test
- public void test_applyGraphThrottle_ignoresAnOrdinaryResponse() {
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final CurlResponse ok = new CurlResponse();
- ok.setHttpStatusCode(200);
- final CurlResponse forbidden = new CurlResponse();
- forbidden.setHttpStatusCode(403);
-
- authenticator.applyGraphThrottle(ok);
- authenticator.applyGraphThrottle(forbidden);
-
- assertEquals(0L, authenticator.graphThrottledUntil);
- assertFalse(authenticator.isGraphThrottled());
- }
-
- @Test
- public void test_parseRetryAfterSeconds_fallsBackAndStaysBounded() {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
-
- assertEquals(30L, authenticator.parseRetryAfterSeconds(" 30 "));
- assertEquals(60L, authenticator.parseRetryAfterSeconds(null));
- assertEquals(60L, authenticator.parseRetryAfterSeconds(""));
- assertEquals(60L, authenticator.parseRetryAfterSeconds("0"));
- // RFC 9110 also allows an HTTP-date; Graph sends delay-seconds, so this is a fallback.
- assertEquals(60L, authenticator.parseRetryAfterSeconds("Wed, 21 Oct 2026 07:28:00 GMT"));
- // An unbounded value would leave nested groups unresolved for the rest of the day.
- assertEquals(3600L, authenticator.parseRetryAfterSeconds("999999"));
- }
-
- @Test
- public void test_getParentGroup_skipsTheWalkWhileGraphIsThrottling() {
- // 15.7 cached an empty result for the cache TTL, which #3223 removed because it silently
- // took the parent group permissions away for ten minutes. The backoff replaces it: the
- // walk is skipped while Graph asked us to wait, nothing is written to the cache, and it
- // resumes by itself.
- final ScriptedAuthenticator authenticator = newScriptedAuthenticator();
- ComponentUtil.register(new SystemHelper() {
- @Override
- public long getCurrentTimeAsLong() {
- return clock.get();
- }
- }, "systemHelper");
- authenticator.parents.put("group-a", new String[] { "group-b" });
- authenticator.graphThrottledUntil = clock.get() + 60_000L;
-
- final Pair throttled = authenticator.getParentGroup(null, "group-a", 0);
-
- assertEquals(0, throttled.getFirst().length);
- assertTrue(authenticator.lookups.isEmpty());
- assertNull(authenticator.groupCache.getIfPresent(authenticator.buildGroupCacheKey("group-a")), "a skipped walk must not be cached");
-
- clock.addAndGet(60_000L);
- final Pair recovered = authenticator.getParentGroup(null, "group-a", 0);
-
- assertEquals(1, recovered.getFirst().length);
- assertEquals("group-b", recovered.getFirst()[0]);
- }
-
- @Test
- public void test_getParentGroup_stillServesACachedAnswerWhileThrottling() {
- // The backoff exists to stop Graph being called, not to throw away memberships that were
- // already resolved.
- final ScriptedAuthenticator authenticator = newScriptedAuthenticator();
- ComponentUtil.register(new SystemHelper() {
- @Override
- public long getCurrentTimeAsLong() {
- return clock.get();
- }
- }, "systemHelper");
- authenticator.parents.put("group-a", new String[] { "group-b" });
- assertEquals(1, authenticator.getParentGroup(null, "group-a", 0).getFirst().length);
-
- authenticator.graphThrottledUntil = clock.get() + 60_000L;
-
- assertEquals(1, authenticator.getParentGroup(null, "group-a", 0).getFirst().length);
- }
-
- @Test
- public void test_getStateTtl_defaultsToOneHourInSeconds() {
- // removeExpiredStates compares (now - created) / 1000 against this value, so the unit is
- // seconds. The javadoc used to say milliseconds.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- assertEquals(3600L, authenticator.getStateTtl());
- }
-
- @Test
- public void test_getStateTtl_fallsBackWhenTheConfiguredValueIsNotANumber() {
- // A typo in conf/system.properties used to fail the login with a NumberFormatException
- // rather than a message anyone could act on.
- ComponentUtil.getFessConfig().setSystemProperty("entraid.state.ttl", "one hour");
- try {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- assertEquals(3600L, authenticator.getStateTtl());
- } finally {
- ComponentUtil.getFessConfig().setSystemProperty("entraid.state.ttl", "");
- }
- }
-
- @Test
- public void test_getStateTtl_fallsBackWhenTheConfiguredValueIsNotPositive() {
- // Long.parseLong accepts "0" and "-1", so these used to be taken at face value.
- // removeExpiredStates then dropped every state one second after it was created, and the
- // user -- who spends longer than that signing in at Microsoft -- came back to
- // "could not validate state" on every attempt. Nothing in the log named the setting, so
- // the server looked broken rather than misconfigured.
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- for (final String value : new String[] { "0", "-1" }) {
- final LogCapturingAppender logs = LogCapturingAppender.attach(EntraIdAuthenticator.class);
- try {
- fessConfig.setSystemProperty("entraid.state.ttl", value);
-
- assertEquals(3600L, new EntraIdAuthenticator().getStateTtl());
- assertTrue(logs.warnings().toString(),
- logs.warnings().stream().anyMatch(m -> m.contains("entraid.state.ttl") && m.contains(value)));
- } finally {
- logs.detach();
- fessConfig.setSystemProperty("entraid.state.ttl", "");
- }
- }
- }
-
- @Test
- public void test_getPermissionFieldValue_ignoresAFieldThatIsNotAString() {
- // entraid.permission.fields is a list of Graph field names, and the documentation does not
- // say the field has to hold a string. securityEnabled and groupTypes are the two most
- // plausible wrong answers -- they are on every group object -- and both used to throw
- // ClassCastException out of the middle of processDirectMemberOf, where the catch turned a
- // single mistyped field name into "no group permissions at all" for the whole tenant.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final Map group = new HashMap<>();
- group.put("displayName", "Engineering");
- group.put("securityEnabled", Boolean.TRUE);
- group.put("groupTypes", Arrays.asList("Unified"));
-
- final LogCapturingAppender logs = LogCapturingAppender.attach(EntraIdAuthenticator.class);
- try {
- assertEquals("Engineering", authenticator.getPermissionFieldValue(group, "displayName"));
- assertNull(authenticator.getPermissionFieldValue(group, "securityEnabled"));
- assertNull(authenticator.getPermissionFieldValue(group, "groupTypes"));
- // Absent is not a misconfiguration worth a warning: a group without a mail address is
- // the normal case for a security group.
- assertNull(authenticator.getPermissionFieldValue(group, "mail"));
-
- assertEquals(logs.warnings().toString(), 2L,
- logs.warnings().stream().filter(m -> m.contains("entraid.permission.fields")).count());
- assertTrue(logs.warnings().toString(), logs.warnings().stream().anyMatch(m -> m.contains("securityEnabled")));
- assertTrue(logs.warnings().toString(), logs.warnings().stream().anyMatch(m -> m.contains("groupTypes")));
- } finally {
- logs.detach();
- }
- }
-
- @Test
- public void test_getPermissionFieldValue_warnsOncePerFieldName() {
- // The read runs once per group per login, so warning every time would bury the rest of the
- // log under a message that says the same thing.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final Map group = new HashMap<>();
- group.put("securityEnabled", Boolean.TRUE);
-
- final LogCapturingAppender logs = LogCapturingAppender.attach(EntraIdAuthenticator.class);
- try {
- for (int i = 0; i < 5; i++) {
- assertNull(authenticator.getPermissionFieldValue(group, "securityEnabled"));
- }
- assertEquals(logs.warnings().toString(), 1L, logs.warnings().stream().filter(m -> m.contains("securityEnabled")).count());
- } finally {
- logs.detach();
- }
- }
-
- @Test
- public void test_getStateTtl_keepsAPositiveValue() {
- // The guard must not round a deliberately short expiry up to the default.
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- try {
- fessConfig.setSystemProperty("entraid.state.ttl", "1");
- assertEquals(1L, new EntraIdAuthenticator().getStateTtl());
- } finally {
- fessConfig.setSystemProperty("entraid.state.ttl", "");
- }
- }
-
- @Test
- public void test_addGroupOrRoleName() {
- EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- List list = new ArrayList<>();
-
- list.clear();
- authenticator.addGroupOrRoleName(list, "test", true);
- assertEquals(1, list.size());
- assertEquals("test", list.get(0));
-
- list.clear();
- authenticator.addGroupOrRoleName(list, "test", false);
- assertEquals(1, list.size());
- assertEquals("test", list.get(0));
-
- list.clear();
- authenticator.addGroupOrRoleName(list, "test@codelibs.org", true);
- assertEquals(2, list.size());
- assertEquals("test@codelibs.org", list.get(0));
- assertEquals("test", list.get(1));
-
- list.clear();
- authenticator.addGroupOrRoleName(list, "test@codelibs.org", false);
- assertEquals(1, list.size());
- assertEquals("test@codelibs.org", list.get(0));
-
- list.clear();
- authenticator.addGroupOrRoleName(list, "test@codelibs.org@hoge.com", true);
- assertEquals(2, list.size());
- assertEquals("test@codelibs.org@hoge.com", list.get(0));
- assertEquals("test", list.get(1));
-
- }
-
- @Test
- public void test_setGroupCacheExpiry_isHonouredByTheCacheItBuilds() {
- // The setter is what a fess_sso+entraidAuthenticator.xml writes, so it has to reach the
- // builder rather than just a field nothing reads.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- authenticator.setGroupCacheExpiry(0L);
-
- final Cache> expiringCache = authenticator.createGroupCache();
- expiringCache.put("group-a", new Pair<>(new String[0], new String[0]));
- expiringCache.cleanUp();
- assertNull(expiringCache.getIfPresent("group-a"));
-
- authenticator.setGroupCacheExpiry(600L);
- final Cache> livingCache = authenticator.createGroupCache();
- livingCache.put("group-a", new Pair<>(new String[0], new String[0]));
- livingCache.cleanUp();
- assertNotNull(livingCache.getIfPresent("group-a"));
- }
-
- @Test
- public void test_getParentGroup_withDepthLimit() {
- EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- authenticator.setMaxGroupDepth(2);
-
- // Test that depth limit returns empty arrays when depth is exceeded
- // With depth limit set to 2, depth 10 should return empty arrays
- Pair result = authenticator.getParentGroup(null, "test-id", 10);
- assertNotNull(result);
- assertEquals(0, result.getFirst().length);
- assertEquals(0, result.getSecond().length);
- }
-
- @Test
- public void test_getParentGroup_exactlyAtDepthLimit() {
- EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- authenticator.setMaxGroupDepth(5);
-
- // Test with depth exactly at the limit - should return empty arrays
- Pair result = authenticator.getParentGroup(null, "test-id", 5);
- assertNotNull(result);
- assertEquals(0, result.getFirst().length);
- assertEquals(0, result.getSecond().length);
- }
-
- @Test
- public void test_getParentGroup_oneBeforeDepthLimit() {
- // The sibling tests above pin the depth check returning early. This one has to prove the
- // opposite -- that one below the limit the lookup really is attempted -- so it needs a
- // cache and a scripted Graph. Without them getParentGroup throws NullPointerException on
- // the null groupCache, which the version of this test inherited from 15.7 swallowed along
- // with everything else.
- final ScriptedAuthenticator authenticator = newScriptedAuthenticator();
- authenticator.setMaxGroupDepth(5);
- authenticator.parents.put("test-id", new String[] { "parent-id" });
-
- final Pair result = authenticator.getParentGroup(null, "test-id", 4);
-
- assertEquals(1, result.getFirst().length);
- assertEquals("parent-id", result.getFirst()[0]);
- assertTrue(authenticator.lookups.contains("test-id"));
- }
-
- @Test
- public void test_processParentGroup_callsOverloadWithDepth() {
- // maxGroupDepth is 1, so the walk happens only if the overload starts at depth 0. Passing
- // anything else through would make this silently do nothing.
- final ScriptedAuthenticator authenticator = newScriptedAuthenticator();
- authenticator.setMaxGroupDepth(1);
- authenticator.parents.put("test-id", new String[] { "parent-id" });
- final List groupList = new ArrayList<>();
- final List roleList = new ArrayList<>();
-
- authenticator.processParentGroup(null, groupList, roleList, "test-id");
-
- assertTrue(authenticator.lookups.contains("test-id"));
- assertEquals(1, groupList.size());
- assertEquals("parent-id", groupList.get(0));
- assertEquals(0, roleList.size());
- }
-
- @Test
- public void test_processParentGroup_respectsDepthLimit() {
- EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- authenticator.setMaxGroupDepth(2);
-
- List groupList = new ArrayList<>();
- List roleList = new ArrayList<>();
-
- // Test with depth exceeding limit - should return immediately
- authenticator.processParentGroup(null, groupList, roleList, "test-id", 5);
-
- // Lists should remain empty as depth limit prevents processing
- assertEquals(0, groupList.size());
- assertEquals(0, roleList.size());
- }
-
- @SuppressWarnings("deprecation")
- @Test
- public void test_setUseV2Endpoint_isKeptAsANoOpForOutOfTreeDiFiles() {
- // Removing the public setter would break a fess_sso+entraidAuthenticator.xml that still
- // sets the property, so it stays -- but it no longer changes anything.
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
-
- authenticator.setUseV2Endpoint(true);
- final String afterTrue = authenticator.getAuthUrl(getMockRequest());
- authenticator.setUseV2Endpoint(false);
- final String afterFalse = authenticator.getAuthUrl(getMockRequest());
-
- assertTrue(afterTrue.contains("/oauth2/v2.0/authorize?"));
- assertTrue(afterFalse.contains("/oauth2/v2.0/authorize?"));
- }
-
- @Test
- public void test_defaultMaxGroupDepth() {
- EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
-
- // Test that default max depth (10) prevents deep recursion
- // Depth 100 should exceed default and return empty
- Pair result = authenticator.getParentGroup(null, "test-id", 100);
- assertNotNull(result);
- assertEquals(0, result.getFirst().length);
- assertEquals(0, result.getSecond().length);
- }
-
- // ========== Tests for lazy loading implementation ==========
-
- /**
- * Test that processDirectMemberOf method exists with correct signature.
- */
- @Test
- public void test_processDirectMemberOf_methodExists() throws Exception {
- Method method = EntraIdAuthenticator.class.getDeclaredMethod("processDirectMemberOf", EntraIdUser.class, List.class, List.class,
- List.class, String.class);
- assertNotNull(method, "processDirectMemberOf method should exist");
- }
-
- /**
- * Test that updateMemberOf still exists and is public.
- */
- @Test
- public void test_updateMemberOf_methodExists() throws Exception {
- Method method = EntraIdAuthenticator.class.getMethod("updateMemberOf", EntraIdUser.class);
- assertNotNull(method, "updateMemberOf method should exist");
- assertTrue("updateMemberOf should be public", java.lang.reflect.Modifier.isPublic(method.getModifiers()));
- }
-
- /**
- * Test processDirectMemberOf collects group IDs for parent lookup.
- */
- @Test
- public void test_processDirectMemberOf_collectsGroupIds() throws Exception {
- // The parsing loop had no coverage at all: the version of this test inherited from 15.7
- // pointed at an unreachable URL and then asserted that three lists it had just created
- // were not null.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final EntraIdUser user = newUserWithoutGraph();
- final List groupList = new ArrayList<>();
- final List roleList = new ArrayList<>();
- final List groupIdsForParentLookup = new ArrayList<>();
-
- try (GraphStub graph = new GraphStub(200, Map.of(),
- "{\"value\":[{\"@odata.type\":\"#microsoft.graph.group\",\"id\":\"group-1\",\"mail\":\"sales@contoso.com\"},"
- + "{\"@odata.type\":\"#microsoft.graph.directoryRole\",\"id\":\"role-1\"}]}")) {
- assertTrue(authenticator.processDirectMemberOf(user, groupList, roleList, groupIdsForParentLookup, graph.url()));
- }
-
- // entraid.permission.fields defaults to "mail" and entraid.use.ds to true, so the mail
- // address contributes both itself and its local part.
- assertEquals(List.of("group-1", "sales@contoso.com", "sales"), groupList);
- assertEquals(List.of("role-1"), roleList);
- // Only groups are walked for parents; a directory role has none.
- assertEquals(List.of("group-1"), groupIdsForParentLookup);
- }
-
- /**
- * Test that default groups and roles are read from configuration, new key first.
- */
- @Test
- public void test_defaultGroupsAndRoles_readTheLegacyAzureAdKeys() {
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- try {
- assertEquals(Collections.emptyList(), authenticator.getDefaultGroupList());
- assertEquals(Collections.emptyList(), authenticator.getDefaultRoleList());
-
- // Blank entries are dropped and the rest trimmed, so a hand-edited list still works.
- fessConfig.setSystemProperty("aad.default.groups", " everyone , , guests ");
- fessConfig.setSystemProperty("aad.default.roles", " guest ");
- assertEquals(List.of("everyone", "guests"), authenticator.getDefaultGroupList());
- assertEquals(List.of("guest"), authenticator.getDefaultRoleList());
-
- // The renamed key wins over the legacy one.
- fessConfig.setSystemProperty("entraid.default.groups", "staff");
- fessConfig.setSystemProperty("entraid.default.roles", "member");
- assertEquals(List.of("staff"), authenticator.getDefaultGroupList());
- assertEquals(List.of("member"), authenticator.getDefaultRoleList());
- } finally {
- fessConfig.setSystemProperty("aad.default.groups", "");
- fessConfig.setSystemProperty("aad.default.roles", "");
- fessConfig.setSystemProperty("entraid.default.groups", "");
- fessConfig.setSystemProperty("entraid.default.roles", "");
- }
- }
-
- /**
- * Test that the client application settings fall back to the legacy Azure AD keys.
- */
- @Test
- public void test_clientConfiguration_fallsBackToTheLegacyAzureAdKeys() {
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- try {
- fessConfig.setSystemProperty("aad.client.id", "legacy-client-id");
- fessConfig.setSystemProperty("aad.client.secret", "legacy-secret");
- fessConfig.setSystemProperty("aad.tenant", "legacy-tenant");
- fessConfig.setSystemProperty("aad.state.ttl", "120");
- fessConfig.setSystemProperty("aad.authority", "https://login.microsoftonline.us/");
-
- assertEquals("legacy-client-id", authenticator.getClientId());
- assertEquals("legacy-secret", authenticator.getClientSecret());
- assertEquals("legacy-tenant", authenticator.getTenant());
- assertEquals(120L, authenticator.getStateTtl());
- assertEquals("https://login.microsoftonline.us/", authenticator.getAuthority());
- } finally {
- fessConfig.setSystemProperty("aad.client.id", "");
- fessConfig.setSystemProperty("aad.client.secret", "");
- fessConfig.setSystemProperty("aad.tenant", "");
- fessConfig.setSystemProperty("aad.state.ttl", "");
- fessConfig.setSystemProperty("aad.authority", "");
- }
- }
-
- /**
- * Test that processParentGroup handles null user gracefully when depth limit is reached.
- */
- @Test
- public void test_processParentGroup_nullUser_depthExceeded() {
- EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- authenticator.setMaxGroupDepth(5);
-
- List groupList = new ArrayList<>();
- List roleList = new ArrayList<>();
-
- // With depth >= maxGroupDepth, should return immediately without error
- authenticator.processParentGroup(null, groupList, roleList, "test-id", 10);
-
- assertEquals("groupList should remain empty", 0, groupList.size());
- assertEquals("roleList should remain empty", 0, roleList.size());
- }
-
- /**
- * Test addGroupOrRoleName with null value handling.
- */
- @Test
- public void test_addGroupOrRoleName_withEmptyValue() {
- EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- List list = new ArrayList<>();
-
- // Empty string should still be added
- authenticator.addGroupOrRoleName(list, "", true);
- assertEquals(1, list.size());
- assertEquals("", list.get(0));
- }
-
- // ===================================================================================
- // Regressions guarded from 15.7
- // ==============================
-
- @Test
- public void test_updateMemberOf_keepsTheResolvedGroupsWhenGraphReportsAnError() {
- // godHandPrologue refreshes the user on every action request, so updateMemberOf can run
- // long after login. A throttled or newly unauthorised Graph used to leave the user with
- // the configured defaults alone, silently taking away every permission they logged in
- // with, until some later call happened to succeed.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- return false;
- }
- };
- final EntraIdUser user = newUserWithoutGraph();
- user.setGroups(new String[] { "group-a", "group-b" });
- user.setRoles(new String[] { "role-a" });
- // What makes this a re-resolution rather than a first one. Not inferable from the
- // memberships any more: the constructor seeds the configured defaults.
- user.markResolutionCompleted();
-
- authenticator.updateMemberOf(user);
-
- assertEquals(2, user.getGroupNames().length);
- assertEquals("group-a", user.getGroupNames()[0]);
- assertEquals(1, user.getRoleNames().length);
- }
-
- @Test
- public void test_updateMemberOf_degradesToTheDefaultsWhenTheFirstLookupFails() {
- // The default, and what 15.7 did. Failing the login instead turns a Graph 429/503, a
- // transport failure, the 15.8 graphConnectTimeout/graphReadTimeout expiring, or a
- // GroupMember.Read.All permission that was never granted, into a refusal for every user
- // in the tenant for as long as the condition lasts.
- final EntraIdAuthenticator authenticator = newAuthenticatorWhoseLookupFails();
- final EntraIdUser user = newUserWithoutGraph();
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- final LogCapturingAppender logs = LogCapturingAppender.attach(EntraIdAuthenticator.class);
- try {
- fessConfig.setSystemProperty("entraid.default.groups", "everyone");
- fessConfig.setSystemProperty("entraid.default.roles", "guest");
-
- authenticator.updateMemberOf(user);
-
- assertEquals(1, user.getGroupNames().length);
- assertEquals("everyone", user.getGroupNames()[0]);
- assertEquals(1, user.getRoleNames().length);
- assertEquals("guest", user.getRoleNames()[0]);
- // Degrading silently would leave the operator with a tenant of under-permissioned
- // sessions and nothing in the log to explain them.
- assertTrue(logs.warnings().toString(),
- logs.warnings().stream().anyMatch(m -> m.contains(user.getName()) && m.contains("configured defaults")));
- } finally {
- logs.detach();
- fessConfig.setSystemProperty("entraid.default.groups", "");
- fessConfig.setSystemProperty("entraid.default.roles", "");
- }
- }
-
- @Test
- public void test_updateMemberOf_keepsWhatWasCollectedBeforeTheFailure() {
- // processDirectMemberOf pages through /me/memberOf and adds as it goes, so a failure on a
- // later page leaves the earlier ones in the list. The result is always a superset of the
- // configured defaults, which are seeded before the lookup runs.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- groupList.add("group-from-the-first-page");
- return false;
- }
- };
- final EntraIdUser user = newUserWithoutGraph();
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- try {
- fessConfig.setSystemProperty("entraid.default.groups", "everyone");
-
- authenticator.updateMemberOf(user);
-
- assertEquals(List.of("everyone", "group-from-the-first-page"), List.of(user.getGroupNames()));
- } finally {
- fessConfig.setSystemProperty("entraid.default.groups", "");
- }
- }
-
- /** An authenticator whose direct membership lookup always reports a failure. */
- private EntraIdAuthenticator newAuthenticatorWhoseLookupFails() {
- return new EntraIdAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- return false;
- }
- };
- }
-
- @Test
- public void test_updateMemberOf_appliesTheDefaultsWhenTheLookupFindsNoMemberships() {
- // A user who genuinely belongs to nothing is a successful answer, not a failure, and the
- // configured defaults are exactly what they are meant to get.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- return true;
- }
- };
- final EntraIdUser user = newUserWithoutGraph();
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- try {
- fessConfig.setSystemProperty("entraid.default.groups", "everyone");
- fessConfig.setSystemProperty("entraid.default.roles", "guest");
-
- authenticator.updateMemberOf(user);
-
- assertEquals(1, user.getGroupNames().length);
- assertEquals("everyone", user.getGroupNames()[0]);
- assertEquals(1, user.getRoleNames().length);
- assertEquals("guest", user.getRoleNames()[0]);
- } finally {
- fessConfig.setSystemProperty("entraid.default.groups", "");
- fessConfig.setSystemProperty("entraid.default.roles", "");
- }
- }
-
- @Test
- public void test_processDirectMemberOf_reportsATransportFailureInsteadOfLettingItEscape() {
- // curl4j reports a transport failure as CurlException, which is unchecked, so
- // catch (IOException) alone let it escape as an unhandled RuntimeException -- out of the
- // EntraIdUser constructor and past every caller. The same one-line widening is applied to
- // processGroup, whose Graph URL is not injectable and so is covered by inspection only.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- authenticator.setGraphConnectTimeout(500);
- authenticator.setGraphReadTimeout(500);
- final EntraIdUser user = newUserWithoutGraph();
- final int deadPort;
- try (ServerSocket socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
- deadPort = socket.getLocalPort();
- } catch (final IOException e) {
- throw new IllegalStateException(e);
- }
-
- final boolean resolved = authenticator.processDirectMemberOf(user, new ArrayList<>(), new ArrayList<>(), new ArrayList<>(),
- "http://127.0.0.1:" + deadPort + "/v1.0/me/memberOf");
-
- assertFalse(resolved);
- }
-
- @Test
- public void test_processDirectMemberOf_reportsAHostileBodyInsteadOfLettingItEscape() throws Exception {
- // "value" is cast to List>, so a body whose value is an object throws
- // ClassCastException. catch (IOException | CurlException) did not cover it, and it escaped
- // updateMemberOf and the EntraIdUser constructor to the generic error page instead of
- // degrading to the configured defaults.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final EntraIdUser user = newUserWithoutGraph();
-
- try (GraphStub graph = new GraphStub(200, Map.of(), "{\"value\":{\"id\":\"group-1\"}}")) {
- assertFalse(authenticator.processDirectMemberOf(user, new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), graph.url()));
- }
- }
-
- @Test
- public void test_processDirectMemberOf_recordsTheBackoffAThrottledGraphAskedFor() throws Exception {
- // applyGraphThrottle had exactly one call site, inside getMemberGroupIds -- the
- // asynchronous parent group walk. A 429 answered to the synchronous login lookup was
- // therefore neither recorded nor honoured, and the walk went on issuing one request, and
- // one stack trace, per group against a Graph that had already asked us to wait.
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final EntraIdUser user = newUserWithoutGraph();
-
- try (GraphStub graph = new GraphStub(429, Map.of("Retry-After", "120"), "{\"error\":{\"code\":\"TooManyRequests\"}}")) {
- assertFalse(authenticator.processDirectMemberOf(user, new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), graph.url()));
- }
-
- assertEquals(clock.get() + 120_000L, authenticator.graphThrottledUntil);
- assertTrue(authenticator.isGraphThrottled());
- }
-
- @Test
- public void test_processGroup_recordsTheBackoffAThrottledGraphAskedFor() throws Exception {
- // processGroup was the one Microsoft Graph call in this class that never recorded the
- // backoff, and it is the call most likely to meet a 429 first: the parent group walk
- // issues one per member id, against one getMemberGroupIds per group. The gap did not
- // close by itself either -- a 429 whose body is JSON parses, so groupList.add(id) runs
- // and the `!groupList.contains(value)` guard in loadParentGroup then skips the recursion,
- // which is the only other thing on that path that would have reached Graph.
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final EntraIdUser user = newUserWithoutGraph();
- final List groupList = new ArrayList<>();
-
- try (GraphStub graph = new GraphStub(429, Map.of("Retry-After", "120"), "{\"error\":{\"code\":\"TooManyRequests\"}}")) {
- authenticator.processGroup(user, groupList, new ArrayList<>(), "group-a", graph.url());
- }
-
- assertEquals(clock.get() + 120_000L, authenticator.graphThrottledUntil);
- assertTrue(authenticator.isGraphThrottled());
- // The membership itself is unaffected: the id came from getMemberGroups, which is
- // authoritative, and only the permission fields are missing from this degraded answer.
- assertEquals(List.of("group-a"), groupList);
- }
-
- @Test
- public void test_processGroup_leavesTheBackoffAloneOnAnOrdinaryAnswer() throws Exception {
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final EntraIdUser user = newUserWithoutGraph();
- final List groupList = new ArrayList<>();
-
- try (GraphStub graph = new GraphStub(200, Map.of(), "{\"id\":\"group-a\",\"mail\":\"group-a@example.com\"}")) {
- authenticator.processGroup(user, groupList, new ArrayList<>(), "group-a", graph.url());
- }
-
- assertEquals(0L, authenticator.graphThrottledUntil);
- assertFalse(authenticator.isGraphThrottled());
- // Not asserting the permission fields here: entraid.permission.fields is a system
- // property, and those live for the whole JVM, so a sibling test could decide it.
- assertEquals("group-a", groupList.get(0));
- }
-
- @Test
- public void test_processDirectMemberOf_stillAsksWhileGraphIsThrottling() throws Exception {
- // The backoff keeps the asynchronous parent group walk off a throttled Graph. A login has
- // no such luxury: skipping the lookup would hand out the configured defaults alone for the
- // whole backoff, so it has to try -- the tenant may well have recovered already.
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final EntraIdUser user = newUserWithoutGraph();
- authenticator.graphThrottledUntil = clock.get() + 60_000L;
- assertTrue(authenticator.isGraphThrottled());
- final List groupList = new ArrayList<>();
-
- try (GraphStub graph =
- new GraphStub(200, Map.of(), "{\"value\":[{\"@odata.type\":\"#microsoft.graph.group\",\"id\":\"group-1\"}]}")) {
- assertTrue(authenticator.processDirectMemberOf(user, groupList, new ArrayList<>(), new ArrayList<>(), graph.url()));
- }
-
- assertEquals(List.of("group-1"), groupList);
- }
-
- @Test
- public void test_processDirectMemberOf_followsTheNextLinkAcrossPages() throws Exception {
- // A tenant with more direct memberships than Graph returns in one page answers with
- // @odata.nextLink, and the recursion that follows it had never been executed: the stub
- // served one fixed answer, so every test stopped after the first page.
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final EntraIdUser user = newUserWithoutGraph();
- final List groupList = new ArrayList<>();
- final List groupIdsForParentLookup = new ArrayList<>();
-
- try (GraphStub graph = new GraphStub(List.of(
- new StubResponse(200, Map.of(),
- "{\"value\":[{\"@odata.type\":\"#microsoft.graph.group\",\"id\":\"group-1\"}],\"@odata.nextLink\":\"${url}\"}"),
- new StubResponse(200, Map.of(), "{\"value\":[{\"@odata.type\":\"#microsoft.graph.group\",\"id\":\"group-2\"}]}")))) {
- assertTrue(authenticator.processDirectMemberOf(user, groupList, new ArrayList<>(), groupIdsForParentLookup, graph.url()));
- assertEquals(2, graph.requestCount());
- }
-
- assertEquals(List.of("group-1", "group-2"), groupList);
- // Both pages feed the parent group walk, not just the one the first request answered with.
- assertEquals(List.of("group-1", "group-2"), groupIdsForParentLookup);
- }
-
- @Test
- public void test_processDirectMemberOf_reportsAFailureOnALaterPage() throws Exception {
- // Whatever the earlier pages collected stays in the lists. updateMemberOf is what decides
- // between keeping it, writing it with the configured defaults and refusing the login, so
- // this must not be resolved here by returning true for a partial answer.
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final EntraIdUser user = newUserWithoutGraph();
- final List groupList = new ArrayList<>();
-
- try (GraphStub graph = new GraphStub(List.of(
- new StubResponse(200, Map.of(),
- "{\"value\":[{\"@odata.type\":\"#microsoft.graph.group\",\"id\":\"group-1\"}],\"@odata.nextLink\":\"${url}\"}"),
- new StubResponse(500, Map.of(), "{\"error\":{\"code\":\"generalException\"}}")))) {
- assertFalse(authenticator.processDirectMemberOf(user, groupList, new ArrayList<>(), new ArrayList<>(), graph.url()));
- assertEquals(2, graph.requestCount());
- }
-
- assertEquals(List.of("group-1"), groupList);
- }
-
- @Test
- public void test_getMemberGroupIds_returnsTheParentIdsGraphAnswered() throws Exception {
- // toMemberGroupIds is covered directly, but the request around it -- the POST, the
- // securityEnabledOnly body and the applyGraphThrottle call ahead of the parser -- was
- // never executed, because this was the one Graph call in the class with no URL seam.
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final EntraIdUser user = newUserWithoutGraph();
-
- try (GraphStub graph = new GraphStub(200, Map.of(), "{\"value\":[\"parent-a\",\"parent-b\"]}")) {
- assertEquals(List.of("parent-a", "parent-b"), List.of(authenticator.getMemberGroupIds(user, "group-a", graph.url())));
- }
-
- assertEquals(0L, authenticator.graphThrottledUntil);
- }
-
- @Test
- public void test_getMemberGroupIds_recordsTheBackoffBeforeReadingTheBody() throws Exception {
- // applyGraphThrottle runs ahead of the parser on purpose: a throttled reply is not
- // required to be JSON, and the parser throws on one that is not. Recording the backoff
- // afterwards would lose it for exactly the responses it exists to handle, so the body
- // here is deliberately not JSON.
- final EntraIdAuthenticator authenticator = newAuthenticatorWithControlledClock();
- final EntraIdUser user = newUserWithoutGraph();
-
- try (GraphStub graph = new GraphStub(429, Map.of("Retry-After", "120", "Content-Type", "text/plain"), "Too Many Requests")) {
- try {
- authenticator.getMemberGroupIds(user, "group-a", graph.url());
- fail("an unreadable throttled reply must not be mistaken for an answer");
- } catch (final IOException | RuntimeException e) {
- // Expected: getParentGroup turns this into an uncached empty result.
- }
- }
-
- assertEquals(clock.get() + 120_000L, authenticator.graphThrottledUntil);
- assertTrue(authenticator.isGraphThrottled());
- }
-
- @Test
- public void test_removeAccount_doesNotLetALogoutFailBecauseTheCacheCouldNotBePruned() {
- // getClientApplication throws when Entra ID is not configured -- which is the state a
- // server is left in after the settings are cleared while a session is still live -- and
- // LogoutAction has nothing to catch it with.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- setEntraIdConfig("", "", "");
-
- authenticator.removeAccount(new TestAccount());
- }
-
- /**
- * One scripted answer from {@link GraphStub}. {@code Content-Type: application/json} is sent
- * unless {@code headers} overrides it, because that is what Microsoft Graph answers with and
- * what the response parser expects.
- *
- * @param statusCode The HTTP status to answer with.
- * @param headers The headers to add, which may override the default {@code Content-Type}.
- * @param body The body to answer with. See {@link GraphStub} for the {@code ${url}} token.
- */
- private record StubResponse(int statusCode, Map headers, String body) {
- }
-
- /**
- * A local stand-in for the Microsoft Graph endpoint. curl4j does not throw on a non-2xx
- * response, so the status code and the headers are only observable through a real request.
- *
- * The scripted answers are served one per request, in order, and the last one is repeated
- * once the script runs out. {@code ${url}} in a body is replaced by the stub's own URL, which
- * is what lets a paged answer point its {@code @odata.nextLink} back at the stub: the URL is
- * only known once the server has bound a port, so a test cannot write it into the body itself.
- *
- *
The single context is registered under the {@code /me/memberOf} path for every method,
- * which is why the methods driven through it all take their URL as a parameter.
- */
- private static final class GraphStub implements AutoCloseable {
- private final HttpServer server;
- private final String url;
- private final List responses;
- private final AtomicInteger requestCount = new AtomicInteger();
-
- GraphStub(final int statusCode, final Map headers, final String body) throws IOException {
- this(List.of(new StubResponse(statusCode, headers, body)));
- }
-
- GraphStub(final List responses) throws IOException {
- this.responses = responses;
- server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
- // Resolved before the handler is registered rather than after: the body substitution
- // below reads it, and a blank final read from a lambda does not compile.
- url = "http://" + server.getAddress().getAddress().getHostAddress() + ":" + server.getAddress().getPort() + "/v1.0/me/memberOf";
- server.createContext("/v1.0/me/memberOf", exchange -> {
- final StubResponse response = responses.get(Math.min(requestCount.getAndIncrement(), responses.size() - 1));
- final byte[] bytes = response.body().replace("${url}", url).getBytes(StandardCharsets.UTF_8);
- exchange.getResponseHeaders().set("Content-Type", "application/json");
- response.headers().forEach((name, value) -> exchange.getResponseHeaders().set(name, value));
- exchange.sendResponseHeaders(response.statusCode(), bytes.length);
- try (OutputStream out = exchange.getResponseBody()) {
- out.write(bytes);
- }
- });
- server.start();
- }
-
- String url() {
- return url;
- }
-
- int requestCount() {
- return requestCount.get();
- }
-
- @Override
- public void close() {
- server.stop(0);
- }
- }
-
- @Test
- public void test_updateMemberOf_replacesTheGroupsWhenGraphAnswers() {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- groupList.add("group-c");
- return true;
- }
- };
- final EntraIdUser user = newUserWithoutGraph();
- user.setGroups(new String[] { "group-a" });
-
- authenticator.updateMemberOf(user);
-
- assertEquals(1, user.getGroupNames().length);
- assertEquals("group-c", user.getGroupNames()[0]);
- }
-
- /**
- * Counts the notifications the {@link ActivityHelper} stub below receives. A no-op stub
- * registered only to avoid a NullPointerException leaves the notification untested -- deleting
- * the {@code permissionChanged} call from {@code updateMemberOf} kept the whole suite green --
- * so {@code test_updateMemberOf_notifiesTheActivityLogOncePerCompletedResolution} asserts on
- * this instead.
- */
- private final AtomicInteger permissionChangedCount = new AtomicInteger();
-
- /**
- * Builds a user without letting its constructor reach Microsoft Graph. The tests below drive
- * {@code updateMemberOf} directly, so the registered component only has to stay quiet.
- */
- private EntraIdUser newUserWithoutGraph() {
- ComponentUtil.register(new EntraIdAuthenticator() {
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- // keep the constructor off Microsoft Graph
- }
- }, EntraIdAuthenticator.class.getCanonicalName());
- // updateMemberOf calls ComponentUtil.getActivityHelper().permissionChanged(...) itself
- // once it lands, and test_app.xml does not register one. Most of the tests below only care
- // about the EntraIdUser's own state, but the notification is the operator's record that a
- // user's permissions changed, so it is counted rather than swallowed.
- ComponentUtil.register(new ActivityHelper() {
- @Override
- public void permissionChanged(final OptionalThing user) {
- permissionChangedCount.incrementAndGet();
- }
- }, "activityHelper");
- return new EntraIdCredential(new TestAuthenticationResult(new TestAccount())).getUser();
- }
-
- @Test
- public void test_toMemberGroupIds_treatsADeniedPermissionAsAnAnswerSoItCanBeCached() throws Exception {
- // A Graph permission that was never granted will not appear within the cache TTL.
- // Throwing left nothing cached, so every login re-issued one failing request, and one
- // stack trace, per direct group.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final Map contentMap = new HashMap<>();
- contentMap.put("error", Map.of("code", "Authorization_RequestDenied", "message", "Insufficient privileges"));
-
- assertEquals(0, authenticator.toMemberGroupIds(contentMap, "group-a").length);
- }
-
- @Test
- public void test_toMemberGroupIds_treatsAMissingGroupAsAnAnswer() throws Exception {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final Map contentMap = new HashMap<>();
- contentMap.put("error", Map.of("code", "Request_ResourceNotFound", "message", "not found"));
-
- assertEquals(0, authenticator.toMemberGroupIds(contentMap, "group-a").length);
- }
-
- @Test
- public void test_toMemberGroupIds_throwsOnATransientFailure() {
- // Throttling must stay uncached so the parents are resolved on the next attempt.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final Map contentMap = new HashMap<>();
- contentMap.put("error", Map.of("code", "TooManyRequests", "message", "throttled"));
-
- try {
- authenticator.toMemberGroupIds(contentMap, "group-a");
- fail("a throttled response must not be mistaken for an answer");
- } catch (final IOException e) {
- assertTrue(e.getMessage().contains("group-a"));
- }
- }
-
- @Test
- public void test_toMemberGroupIds_throwsWhenTheErrorIsNotAnObject() {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final Map contentMap = new HashMap<>();
- contentMap.put("error", "invalid_grant");
-
- try {
- authenticator.toMemberGroupIds(contentMap, "group-a");
- fail("an unparsable error must not be mistaken for an answer");
- } catch (final IOException e) {
- assertTrue(e.getMessage().contains("group-a"));
- }
- }
-
- @Test
- public void test_toMemberGroupIds_returnsTheValues() throws Exception {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final Map contentMap = new HashMap<>();
- contentMap.put("value", List.of("parent-a", "parent-b"));
-
- assertEquals(2, authenticator.toMemberGroupIds(contentMap, "group-a").length);
- }
-
- @Test
- public void test_refresh_doesNotTouchGraphWhileTheTokenIsStillFresh() {
- // FessBaseAction.godHandPrologue calls refresh() on every action request. Once the MSAL4J
- // application became shared, acquireTokenSilently started succeeding, and every success
- // ran updateMemberOf -- a synchronous Microsoft Graph call on the request thread.
- final AtomicBoolean touched = new AtomicBoolean(false);
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- touched.set(true);
- }
-
- @Override
- public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) {
- touched.set(true);
- return null;
- }
- };
- ComponentUtil.register(authenticator, EntraIdAuthenticator.class.getCanonicalName());
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
- final EntraIdUser user =
- new EntraIdCredential(new TestAuthenticationResult(new TestAccount(), new Date(now + 60 * 60 * 1000L))).getUser();
- touched.set(false);
-
- assertTrue(user.refresh());
- assertFalse(touched.get());
- }
-
- @Test
- public void test_refresh_acquiresSilentlyOnceTheTokenIsCloseToExpiring() {
- final AtomicBoolean refreshed = new AtomicBoolean(false);
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- // keep the constructor and the refresh off Microsoft Graph
- }
-
- @Override
- public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) {
- refreshed.set(true);
- return null;
- }
- };
- ComponentUtil.register(authenticator, EntraIdAuthenticator.class.getCanonicalName());
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
- final EntraIdUser user =
- new EntraIdCredential(new TestAuthenticationResult(new TestAccount(), new Date(now + 30 * 1000L))).getUser();
-
- assertTrue(user.refresh());
- assertTrue(refreshed.get());
- }
-
- @Test
- public void test_refresh_reportsAnExpiredToken() {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- // keep the constructor off Microsoft Graph
- }
- };
- ComponentUtil.register(authenticator, EntraIdAuthenticator.class.getCanonicalName());
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
- final EntraIdUser user = new EntraIdCredential(new TestAuthenticationResult(new TestAccount(), new Date(now - 1000L))).getUser();
-
- assertFalse(user.refresh());
- }
-
- @Test
- public void test_getAuthUrl_usesTheConfiguredResponseMode() {
- // 15.7 hard-coded form_post and 15.8 hard-codes query. A deployment that sets
- // tomcat.sameSiteCookies=none may prefer form_post to keep the authorization code out of
- // the callback URL, so the mode has to be selectable rather than compiled in.
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final HttpServletRequest request = newAuthUrlRequest();
- try {
- assertTrue(authenticator.getAuthUrl(request).contains("&response_mode=query&"));
-
- fessConfig.setSystemProperty("entraid.response.mode", "form_post");
- assertTrue(authenticator.getAuthUrl(request).contains("&response_mode=form_post&"));
- } finally {
- fessConfig.setSystemProperty("entraid.response.mode", "");
- }
- }
-
- @Test
- public void test_getResponseMode_fallsBackWhenTheConfiguredValueIsNotAResponseMode() {
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- try {
- fessConfig.setSystemProperty("entraid.response.mode", "fragment");
- assertEquals("query", authenticator.getResponseMode());
- } finally {
- fessConfig.setSystemProperty("entraid.response.mode", "");
- }
- }
-
- @Test
- public void test_getResponseMode_readsTheLegacyAzureAdKey() {
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- try {
- fessConfig.setSystemProperty("aad.response.mode", " form_post ");
- assertEquals("form_post", authenticator.getResponseMode());
- } finally {
- fessConfig.setSystemProperty("aad.response.mode", "");
- }
- }
-
- @Test
- public void test_validateState_reportsACallbackThatMatchesNoLogin() {
- // The SSO endpoint is anonymous, so anyone can send a state this server never issued.
- // SsoAction logs SsoStateException without a stack trace so that cannot fill the log.
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- final HttpSession session = newAuthUrlRequest().getSession();
- try {
- authenticator.validateState(session, "never-issued");
- fail("expected SsoStateException");
- } catch (final SsoStateException e) {
- assertEquals("could not validate state", e.getMessage());
- }
- }
-
- @Test
- public void test_validateNonce_reportsAMismatchAsAStateFailure() {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- try {
- authenticator.validateNonce(new EntraIdAuthenticator.StateData("expected-nonce", 0L),
- new TestAuthenticationResult(new TestAccount(), plainIdToken("some-other-nonce")));
- fail("expected SsoStateException");
- } catch (final SsoStateException e) {
- assertEquals("could not validate nonce", e.getMessage());
- }
- }
-
- @Test
- public void test_validateNonce_acceptsTheNonceItIssued() throws Exception {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- authenticator.validateNonce(new EntraIdAuthenticator.StateData("expected-nonce", 0L),
- new TestAuthenticationResult(new TestAccount(), plainIdToken("expected-nonce")));
- }
-
- @Test
- public void test_validateNonce_keepsTheStackTraceOfAnUnreadableIdToken() {
- // Only reachable once the authorization code was redeemed, so this is a fault an operator
- // has to be able to diagnose -- not a callback someone sent us. It must not be reduced to
- // the stack-free SsoStateException log line.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- try {
- authenticator.validateNonce(new EntraIdAuthenticator.StateData("expected-nonce", 0L),
- new TestAuthenticationResult(new TestAccount(), "not-a-jwt"));
- fail("expected SsoLoginException");
- } catch (final SsoStateException e) {
- fail("an unreadable ID token must keep its cause: " + e);
- } catch (final SsoLoginException e) {
- assertNotNull(e.getCause());
- }
- }
-
- /** Builds an unsigned JWT carrying the given nonce; validateNonce only reads the claims. */
- private String plainIdToken(final String nonce) {
- final java.util.Base64.Encoder encoder = java.util.Base64.getUrlEncoder().withoutPadding();
- return encoder.encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8)) + "."
- + encoder.encodeToString(("{\"nonce\":\"" + nonce + "\"}").getBytes(StandardCharsets.UTF_8)) + ".";
- }
-
- @Test
- public void test_getAuthority_fallsBackWhenTheLegacyKeyIsPresentButBlank() {
- // getSystemProperty returns the default only when the key is absent, so an aad.authority
- // that exists and is empty came back as "". getAuthUrl then built a scheme-less, and
- // therefore relative, URL that redirected the browser back inside Fess.
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- try {
- fessConfig.setSystemProperty("aad.authority", "");
-
- assertEquals("https://login.microsoftonline.com/", authenticator.getAuthority());
- } finally {
- fessConfig.setSystemProperty("aad.authority", "");
- }
- }
-
- @Test
- public void test_getResponseMode_ignoresABlankLegacyKey() {
- // getSystemProperty only applies the default when the key is absent, so a key left empty
- // by the admin screen would otherwise warn about entraid.response.mode on every redirect.
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- try {
- fessConfig.setSystemProperty("aad.response.mode", "");
- assertEquals("query", authenticator.getResponseMode());
- } finally {
- fessConfig.setSystemProperty("aad.response.mode", "");
- }
- }
-
- @Test
- public void test_refresh_doesNotReReadTheDirectoryForAnUnchangedToken() {
- // MSAL4J rounds its expiry buffer down to whole seconds, so around REFRESH_MARGIN it
- // returns the token it already had. Treating that as a renewal would restore the
- // per-request Microsoft Graph call.
- final AtomicBoolean updated = new AtomicBoolean(false);
- final AtomicReference current = new AtomicReference<>();
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- updated.set(true);
- }
-
- @Override
- public IAuthenticationResult refreshTokenSilently(final EntraIdUser user) {
- return current.get();
- }
- };
- ComponentUtil.register(authenticator, EntraIdAuthenticator.class.getCanonicalName());
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
- final TestAuthenticationResult sameToken = new TestAuthenticationResult(new TestAccount(), new Date(now + 30 * 1000L));
- current.set(sameToken);
- final EntraIdUser user = new EntraIdCredential(sameToken).getUser();
- updated.set(false);
-
- assertTrue(user.refresh());
- assertFalse(updated.get());
-
- current.set(new TestAuthenticationResult(new TestAccount(), new Date(now + 30 * 1000L), "renewed-access-token"));
- assertTrue(user.refresh());
- assertTrue(updated.get());
- }
-
- private HttpServletRequest newAuthUrlRequest() {
- final MockletHttpServletRequest request = getMockRequest();
- request.setMethod("GET");
- return request;
- }
-
- @Test
- public void test_updateMemberOf_marksTheFirstResolutionResolved() {
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- groupList.add("group-a");
- return true;
- }
- };
- final EntraIdUser user = newUserWithoutGraph();
- assertEquals(FessUser.PermissionState.PENDING, user.getPermissionState());
-
- authenticator.updateMemberOf(user);
-
- assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState());
- }
-
- @Test
- public void test_updateMemberOf_marksAFailedFirstResolutionFailed() {
- // The login still completes -- the user keeps their user-level permission and the
- // configured defaults -- but the shortfall has to be visible rather than silent.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- return false;
- }
- };
- final EntraIdUser user = newUserWithoutGraph();
-
- authenticator.updateMemberOf(user);
-
- assertEquals(FessUser.PermissionState.FAILED, user.getPermissionState());
- assertNotNull(user.getGroupNames());
- }
-
- @Test
- public void test_updateMemberOf_leavesTheStateAloneOnAReResolution() {
- // A token renewal re-resolves a user who already holds working permissions. Neither
- // PENDING nor FAILED is true of them, so neither may be written.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- return false;
- }
- };
- final EntraIdUser user = newUserWithoutGraph();
- user.setGroups(new String[] { "group-a" });
- user.setPermissionState(FessUser.PermissionState.RESOLVED);
- user.markResolutionCompleted();
-
- authenticator.updateMemberOf(user);
-
- assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState());
- assertEquals("group-a", user.getGroupNames()[0]);
- }
-
- @Test
- public void test_updateMemberOf_clearsAFailedStateOnceAReResolutionSucceeds() {
- // The user's first lookup failed, so they are FAILED and hold the defaults. A token
- // renewal an hour later re-resolves them successfully -- they now genuinely hold their
- // groups, and going on telling them their permissions could not be loaded would be a lie.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- groupList.add("group-a");
- return true;
- }
- };
- final EntraIdUser user = newUserWithoutGraph();
- user.setGroups(new String[] { "everyone" });
- user.setPermissionState(FessUser.PermissionState.FAILED);
- user.markResolutionCompleted();
-
- authenticator.updateMemberOf(user);
-
- assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState());
- assertEquals("group-a", user.getGroupNames()[0]);
- }
-
- @Test
- public void test_constructor_doesNotReachGraphOnTheLoginThread() {
- // The login thread must not wait on Microsoft Graph. A slow tenant used to delay every
- // login by up to the read timeout.
- final AtomicBoolean scheduled = new AtomicBoolean();
- ComponentUtil.register(new EntraIdAuthenticator() {
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- scheduled.set(true);
- }
-
- @Override
- public void updateMemberOf(final EntraIdUser user) {
- fail("updateMemberOf must not run on the login thread");
- }
- }, EntraIdAuthenticator.class.getCanonicalName());
-
- final EntraIdUser user = new EntraIdCredential(new TestAuthenticationResult(new TestAccount())).getUser();
-
- assertTrue(scheduled.get());
- assertEquals(FessUser.PermissionState.PENDING, user.getPermissionState());
- // Seeded with the configured defaults, of which there are none here -- not left null.
- assertEquals(0, user.getGroupNames().length);
- assertEquals(0, user.getRoleNames().length);
- assertFalse(user.isResolutionCompleted());
- }
-
- // ===================================================================================
- // Defaults During the PENDING Window
- // ==================================
- // entraid.default.groups/roles are static configuration -- no Graph call stands behind them --
- // so there is no reason for them to be absent while the background resolution runs. SsoAction
- // redirects straight to the search page in the request that only schedules it, so without the
- // seed the first page after login is that of a user holding no groups at all.
-
- @Test
- public void test_constructor_seedsTheConfiguredDefaultsBeforeAnyResolution() {
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- try {
- fessConfig.setSystemProperty("entraid.default.groups", "everyone");
- fessConfig.setSystemProperty("entraid.default.roles", "guest");
-
- final EntraIdUser user = newUserWithoutGraph();
-
- assertEquals(List.of("everyone"), List.of(user.getGroupNames()));
- assertEquals(List.of("guest"), List.of(user.getRoleNames()));
- // Seeding is not resolving: the state must still say so, and the next updateMemberOf
- // must still be treated as the first resolution.
- assertEquals(FessUser.PermissionState.PENDING, user.getPermissionState());
- assertFalse(user.isResolutionCompleted());
- } finally {
- fessConfig.setSystemProperty("entraid.default.groups", "");
- fessConfig.setSystemProperty("entraid.default.roles", "");
- }
- }
-
- @Test
- public void test_getPermissions_appliesTheSeededDefaultsWhileStillPending() {
- // The seed is only worth having if it reaches the permissions the search query is built
- // from, which are computed lazily and cached.
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- try {
- fessConfig.setSystemProperty("entraid.default.groups", "everyone");
-
- final EntraIdUser user = newUserWithoutGraph();
-
- assertEquals(FessUser.PermissionState.PENDING, user.getPermissionState());
- assertTrue(List.of(user.getPermissions()).toString(),
- List.of(user.getPermissions()).contains(ComponentUtil.getSystemHelper().getSearchRoleByGroup("everyone")));
- } finally {
- fessConfig.setSystemProperty("entraid.default.groups", "");
- }
- }
-
- @Test
- public void test_updateMemberOf_writesTheResolvedGroupsOnTopOfTheSeededDefaults() {
- // First resolution, Graph answers: the seed is a floor, not a ceiling -- the resolved
- // groups join it rather than replacing it or being dropped in favour of it.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- groupList.add("group-a");
- return true;
- }
- };
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- try {
- fessConfig.setSystemProperty("entraid.default.groups", "everyone");
- final EntraIdUser user = newUserWithoutGraph();
- assertEquals(List.of("everyone"), List.of(user.getGroupNames()));
-
- authenticator.updateMemberOf(user);
-
- assertEquals(List.of("everyone", "group-a"), List.of(user.getGroupNames()));
- assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState());
- assertTrue(user.isResolutionCompleted());
- } finally {
- fessConfig.setSystemProperty("entraid.default.groups", "");
- }
- }
-
- @Test
- public void test_updateMemberOf_keepsTheFirstResolutionsGroupsWhenALaterOneFails() {
- // The re-resolution behaviour driven through the real state machine rather than a
- // hand-set flag: one successful resolution, then a token rollover whose lookup fails.
- // Nothing about the user may change -- these are the groups they are actually searching
- // with, and replacing them with the defaults alone would silently take their results away.
- final AtomicBoolean graphAnswers = new AtomicBoolean(true);
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- if (!graphAnswers.get()) {
- return false;
- }
- groupList.add("group-a");
- return true;
- }
- };
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- try {
- fessConfig.setSystemProperty("entraid.default.groups", "everyone");
- final EntraIdUser user = newUserWithoutGraph();
-
- authenticator.updateMemberOf(user);
- assertEquals(List.of("everyone", "group-a"), List.of(user.getGroupNames()));
- assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState());
-
- graphAnswers.set(false);
- authenticator.updateMemberOf(user);
-
- assertEquals(List.of("everyone", "group-a"), List.of(user.getGroupNames()));
- assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState());
- } finally {
- fessConfig.setSystemProperty("entraid.default.groups", "");
- }
- }
-
- @Test
- public void test_updateMemberOf_keepsTheSeededDefaultsWhenTheFirstLookupFails() {
- // First resolution, Graph does not answer: FAILED, and the seeded defaults are retained
- // rather than the user being left with nothing.
- //
- // This is the case the explicit flag exists for. firstResolution only decides anything on
- // the failure path, and with it inferred from `getGroupNames() == null` the seed would
- // make this look like a re-resolution: updateMemberOf would take the early return, so the
- // user would stay PENDING for the rest of the session -- never marked FAILED, so never
- // told their permissions are incomplete -- and whatever the lookup collected before
- // failing would be thrown away.
- final EntraIdAuthenticator authenticator = newAuthenticatorWhoseLookupFails();
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- try {
- fessConfig.setSystemProperty("entraid.default.groups", "everyone");
- final EntraIdUser user = newUserWithoutGraph();
-
- authenticator.updateMemberOf(user);
-
- assertEquals(List.of("everyone"), List.of(user.getGroupNames()));
- assertEquals(FessUser.PermissionState.FAILED, user.getPermissionState());
- assertTrue(user.isResolutionCompleted());
- } finally {
- fessConfig.setSystemProperty("entraid.default.groups", "");
- }
- }
-
- @Test
- public void test_updateMemberOf_walksParentGroupsWithoutASecondScheduledTask() {
- // The walk used to be a second TimeoutManager task, which published direct-only groups
- // first and then overwrote them. One task writes once.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- groupList.add("group-a");
- groupIdsForParentLookup.add("group-a");
- return true;
- }
-
- @Override
- protected boolean processParentGroup(final EntraIdUser user, final List groupList, final List roleList,
- final String id) {
- groupList.add("parent-of-" + id);
- return true;
- }
-
- @Override
- public void scheduleUpdateMemberOf(final EntraIdUser user) {
- fail("updateMemberOf must resolve the parents itself, not schedule a second task");
- }
- };
- final EntraIdUser user = newUserWithoutGraph();
-
- authenticator.updateMemberOf(user);
-
- final List groups = List.of(user.getGroupNames());
- assertTrue(groups.contains("group-a"));
- assertTrue(groups.contains("parent-of-group-a"));
- assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState());
- }
-
- // ===================================================================================
- // The Parent Group Walk and the Reported State
- // ===========================================
- // The direct lookup answering says nothing about the parent groups: the walk that follows it
- // fails silently, group by group. A user who holds their direct groups and none of their
- // parent groups holds fewer permissions than they should, which is what FAILED is for.
-
- /**
- * A scripted authenticator whose direct lookup succeeds and hands one group id to the parent
- * group walk, so that the walk alone decides the state that gets reported.
- */
- private ScriptedAuthenticator newAuthenticatorWalkingOneDirectGroup() {
- final ScriptedAuthenticator authenticator = new ScriptedAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- groupList.add("group-a");
- groupIdsForParentLookup.add("group-a");
- return true;
- }
- };
- authenticator.groupCache = CacheBuilder.newBuilder().build();
- return authenticator;
- }
-
- @Test
- public void test_updateMemberOf_reportsAParentWalkTheGraphBackoffSkipped() {
- // graphThrottledUntil is a field on the singleton authenticator and is capped at an hour,
- // so one user's 429 skips the walk for the whole tenant for that long. The direct lookup
- // goes on answering, so every user resolved in that window used to be reported RESOLVED
- // while none of them held a single parent group.
- final ScriptedAuthenticator authenticator = newAuthenticatorWalkingOneDirectGroup();
- authenticator.parents.put("group-a", new String[] { "group-b" });
- ComponentUtil.register(new SystemHelper() {
- @Override
- public long getCurrentTimeAsLong() {
- return clock.get();
- }
- }, "systemHelper");
- final EntraIdUser user = newUserWithoutGraph();
- authenticator.graphThrottledUntil = clock.get() + 60_000L;
-
- authenticator.updateMemberOf(user);
-
- assertEquals(FessUser.PermissionState.FAILED, user.getPermissionState());
- // Skipped, not attempted -- and the direct groups are still written, because degrading is
- // the point of the backoff.
- assertTrue(authenticator.lookups.isEmpty());
- assertEquals(List.of("group-a"), List.of(user.getGroupNames()));
- }
-
- @Test
- public void test_updateMemberOf_reportsAParentWalkWhoseLookupFailed() {
- // The other empty pair getParentGroup returns: the cache loader threw, so nothing was
- // cached and nothing was resolved.
- final ScriptedAuthenticator authenticator = newAuthenticatorWalkingOneDirectGroup();
- authenticator.failing.add("group-a");
- final EntraIdUser user = newUserWithoutGraph();
-
- authenticator.updateMemberOf(user);
-
- assertEquals(FessUser.PermissionState.FAILED, user.getPermissionState());
- assertEquals(List.of("group-a"), List.of(user.getGroupNames()));
- }
-
- @Test
- public void test_updateMemberOf_resolvesAWalkThatCompleted() {
- // The other half of the contract: a walk that reached Graph for every group must not be
- // reported as a shortfall, or every Entra ID user would be told their permissions are
- // incomplete.
- final ScriptedAuthenticator authenticator = newAuthenticatorWalkingOneDirectGroup();
- authenticator.parents.put("group-a", new String[] { "group-b" });
- final EntraIdUser user = newUserWithoutGraph();
-
- authenticator.updateMemberOf(user);
-
- assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState());
- assertEquals(List.of("group-a", "group-b"), List.of(user.getGroupNames()));
- }
-
- @Test
- public void test_updateMemberOf_doesNotReportTheConfiguredDepthBoundAsAFailure() {
- // maxGroupDepth is where the walk is meant to stop, not a Graph failure. Counting it would
- // mark every user of a tenant whose nesting is deeper than the bound FAILED for good.
- final ScriptedAuthenticator authenticator = newAuthenticatorWalkingOneDirectGroup();
- authenticator.parents.put("group-a", new String[] { "group-b" });
- authenticator.maxGroupDepth = 0;
- final EntraIdUser user = newUserWithoutGraph();
-
- authenticator.updateMemberOf(user);
-
- assertEquals(FessUser.PermissionState.RESOLVED, user.getPermissionState());
- assertTrue(authenticator.lookups.isEmpty());
- assertEquals(List.of("group-a"), List.of(user.getGroupNames()));
- }
-
- @Test
- public void test_updateMemberOf_notifiesTheActivityLogOncePerCompletedResolution() {
- // The notification is the operator's record that a user's permissions changed, and it was
- // pinned by nothing: the ActivityHelper stubs in this class were registered only to keep
- // ComponentUtil.getActivityHelper() from returning null, so deleting the call from
- // updateMemberOf left the whole suite green.
- final AtomicBoolean graphAnswers = new AtomicBoolean(true);
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- if (!graphAnswers.get()) {
- return false;
- }
- groupList.add("group-a");
- return true;
- }
- };
- final EntraIdUser user = newUserWithoutGraph();
- // Constructing the user only schedules the resolution, so it has nothing to report yet.
- assertEquals(0, permissionChangedCount.get());
-
- authenticator.updateMemberOf(user);
-
- assertEquals(1, permissionChangedCount.get());
-
- // A re-resolution Graph did not answer returns before writing anything, so there is no
- // change to report either -- the notification belongs after the write, not before it.
- graphAnswers.set(false);
- authenticator.updateMemberOf(user);
-
- assertEquals(1, permissionChangedCount.get());
- }
-
- @Test
- public void test_scheduleUpdateMemberOf_marksTheUserFailedWhenUpdateMemberOfThrows() throws Exception {
- // scheduleUpdateMemberOf's own body -- the TimeoutManager wiring and the exception
- // backstop -- has no coverage otherwise: every other test overrides it away. The stub
- // below writes groups and then throws before updateMemberOf would reach the permission
- // state write, the exact window Finding M1 is about: a stale "groups == null" check would
- // leave this user PENDING forever instead of FAILED. TimeoutManager's loop ticks about
- // once a second, so poll for the state with a bounded wait rather than sleeping a fixed
- // amount or asserting immediately.
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator() {
- @Override
- public void updateMemberOf(final EntraIdUser user) {
- user.setGroups(new String[] { "group-a" });
- throw new RuntimeException("boom");
- }
- };
- final EntraIdUser user = newUserWithoutGraph();
- assertEquals(FessUser.PermissionState.PENDING, user.getPermissionState());
-
- authenticator.scheduleUpdateMemberOf(user);
-
- final long deadline = System.currentTimeMillis() + 10_000L;
- while (user.getPermissionState() == FessUser.PermissionState.PENDING && System.currentTimeMillis() < deadline) {
- Thread.sleep(100L);
- }
-
- assertEquals(FessUser.PermissionState.FAILED, user.getPermissionState());
- }
-
- /**
- * Sets the authority and the tenant, reads the joined value and restores both. Both keys are
- * written back as "" rather than removed because getSystemProperty keeps them for the life of
- * the container; the class asks for a one-time container so that stays inside this class.
- */
- private String authorityUrlOf(final String authority, final String tenant) {
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- try {
- fessConfig.setSystemProperty("entraid.authority", authority);
- fessConfig.setSystemProperty("entraid.tenant", tenant);
- return authenticator.getAuthorityUrl();
- } finally {
- fessConfig.setSystemProperty("entraid.authority", "");
- fessConfig.setSystemProperty("entraid.tenant", "");
- }
- }
-
- @Test
- public void test_getAuthorityUrl_keepsASlashTerminatedAuthorityByteIdentical() {
- // The normal case, and the shape DEFAULT_AUTHORITY has. It must not move.
- assertEquals("https://login.microsoftonline.com/contoso.onmicrosoft.com/",
- authorityUrlOf("https://login.microsoftonline.com/", "contoso.onmicrosoft.com"));
- }
-
- @Test
- public void test_getAuthorityUrl_insertsTheMissingSeparator() {
- // https://login.microsoftonline.com is how the endpoint is written everywhere it is
- // documented, so an admin typing it without the trailing slash is the expected mistake.
- // Concatenated raw it produced login.microsoftonline.comcontoso.onmicrosoft.com -- one
- // bogus hostname -- and the browser got NXDOMAIN.
- assertEquals("https://login.microsoftonline.com/contoso.onmicrosoft.com/",
- authorityUrlOf("https://login.microsoftonline.com", "contoso.onmicrosoft.com"));
- }
-
- @Test
- public void test_getAuthorityUrl_leavesASlashPrefixedTenantAlone() {
- // A slashless authority plus a slash-prefixed tenant already joins correctly today. A fix
- // that always appends a slash to the authority would break this working configuration.
- assertEquals("https://login.microsoftonline.com/contoso.onmicrosoft.com/",
- authorityUrlOf("https://login.microsoftonline.com", "/contoso.onmicrosoft.com"));
- }
-
- @Test
- public void test_getAuthorityUrl_collapsesADoubledSeparator() {
- assertEquals("https://login.microsoftonline.com/contoso.onmicrosoft.com/",
- authorityUrlOf("https://login.microsoftonline.com/", "/contoso.onmicrosoft.com"));
- }
-
- @Test
- public void test_getAuthorityUrl_leavesABlankTenantByteIdentical() {
- // validateConfiguration refuses a blank tenant before any of this is reached on the login
- // path, and the doubled slash below is the symptom its own javadoc quotes. Normalising it
- // here would only change a URL an operator has already been told about.
- assertEquals("https://login.microsoftonline.com//", authorityUrlOf("https://login.microsoftonline.com/", ""));
- assertEquals("https://login.microsoftonline.com/", authorityUrlOf("https://login.microsoftonline.com", ""));
- }
-
- @Test
- public void test_getAuthorityUrl_doesNotLowercaseTheAuthority() {
- assertEquals("https://Login.MicrosoftOnline.com/Contoso.onmicrosoft.com/",
- authorityUrlOf("https://Login.MicrosoftOnline.com", "Contoso.onmicrosoft.com"));
- }
-
- @Test
- public void test_getAuthUrl_separatesASlashlessAuthorityFromTheTenant() {
- // Pins the whole authorization URL, not just the join: the failure was silent because the
- // URL is only logged at debug level, so the browser was the first thing to see it.
- ComponentUtil.register(new SystemHelper(), "systemHelper");
- final FessConfig fessConfig = ComponentUtil.getFessConfig();
- final EntraIdAuthenticator authenticator = new EntraIdAuthenticator();
- try {
- fessConfig.setSystemProperty("entraid.authority", "https://login.microsoftonline.com");
- fessConfig.setSystemProperty("entraid.tenant", "contoso.onmicrosoft.com");
- fessConfig.setSystemProperty("entraid.client.id", "11111111-1111-1111-1111-111111111111");
- fessConfig.setSystemProperty("entraid.reply.url", "https://fess.example.com/sso/");
-
- final String authUrl = authenticator.getAuthUrl(newAuthUrlRequest());
-
- final String expected =
- "https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/v2.0/authorize" + "?response_type=code&scope="
- + URLEncoder.encode("openid profile offline_access https://graph.microsoft.com/.default",
- StandardCharsets.UTF_8)
- + "&response_mode=query&redirect_uri="
- + URLEncoder.encode("https://fess.example.com/sso/", StandardCharsets.UTF_8)
- + "&client_id=11111111-1111-1111-1111-111111111111";
- // state and nonce are random per call; everything before them is fixed.
- assertEquals(expected, authUrl.replaceFirst("&state=.*$", ""));
- } finally {
- fessConfig.setSystemProperty("entraid.authority", "");
- fessConfig.setSystemProperty("entraid.tenant", "");
- fessConfig.setSystemProperty("entraid.client.id", "");
- fessConfig.setSystemProperty("entraid.reply.url", "");
- }
- }
-
- /**
- * An authenticator whose direct membership lookup hands back a fixed set of group ids without
- * reaching Microsoft Graph, and whose parent group walk is scripted by {@code walkResult}.
- * Every id the walk is asked for is appended to {@code walked}, so a test can tell how far the
- * walk got as well as what it produced.
- */
- private EntraIdAuthenticator newAuthenticatorWithScriptedWalk(final List groupIds, final List walked,
- final java.util.function.Predicate walkResult, final boolean throttled) {
- return new EntraIdAuthenticator() {
- @Override
- protected boolean processDirectMemberOf(final EntraIdUser user, final List groupList, final List roleList,
- final List groupIdsForParentLookup, final String url) {
- groupIdsForParentLookup.addAll(groupIds);
- groupList.add("direct-group");
- return true;
- }
-
- @Override
- protected boolean processParentGroup(final EntraIdUser user, final List groupList, final List